文章目录
-
- 前言
- 技术栈
- 一、项目结构与数据模型
-
- [1.1 核心数据结构](#1.1 核心数据结构)
- [1.2 Mock 数据示例](#1.2 Mock 数据示例)
- 二、地图初始化
-
- [2.1 注册地图数据](#2.1 注册地图数据)
- [2.2 创建图表实例](#2.2 创建图表实例)
- 三、国家区域渲染
-
- [3.1 数据处理逻辑](#3.1 数据处理逻辑)
- [3.2 生成地图区域数据](#3.2 生成地图区域数据)
- [3.3 地图配置项](#3.3 地图配置项)
- 四、散点特效实现
-
- [4.1 国家坐标映射表](#4.1 国家坐标映射表)
- [4.2 生成散点数据](#4.2 生成散点数据)
- [4.3 散点图系列配置](#4.3 散点图系列配置)
- [4.4 散点与地图的层级关系](#4.4 散点与地图的层级关系)
- 五、交互功能实现
-
- [5.1 地图点击事件](#5.1 地图点击事件)
- [5.2 地图聚焦功能](#5.2 地图聚焦功能)
- [5.3 鼠标悬停效果](#5.3 鼠标悬停效果)
- [5.4 动态更新地图数据](#5.4 动态更新地图数据)
- 六、响应式处理
-
- [6.1 窗口大小变化](#6.1 窗口大小变化)
- [6.2 数据加载时机控制](#6.2 数据加载时机控制)
- 七、样式与布局
-
- [7.1 容器样式](#7.1 容器样式)
- 八、性能优化建议
-
- [8.1 地图数据优化](#8.1 地图数据优化)
- [8.2 事件节流](#8.2 事件节流)
- [8.3 按需加载](#8.3 按需加载)
- 九、常见问题与解决方案
-
- [9.1 国家名称不匹配](#9.1 国家名称不匹配)
- [9.2 散点位置不准确](#9.2 散点位置不准确)
- [9.3 地图层级混乱](#9.3 地图层级混乱)
- [9.4 地图不响应窗口变化](#9.4 地图不响应窗口变化)
- 十、完整示例代码
- 十一、总结
前言
在现代 Web 应用中,地图可视化是一个常见且重要的需求。本文将详细介绍如何使用 ECharts 实现一个功能完善的世界地图,包括国家
区域渲染、散点标记、交互效果等核心功能。
本文基于真实项目经验,聚焦于以下核心内容:
- 🗺️ 世界地图的初始化与配置
- 🎨 国家区域的差异化渲染
- ✨ 散点特效的实现
- 🖱️ 地图交互与事件处理
- 📊 数据格式与状态管理
技术栈
- Vue 3 - 前端框架
- ECharts 5.x - 图表库
- TypeScript - 类型支持
- @amcharts/amcharts5-geodata - 地图数据源
一、项目结构与数据模型
1.1 核心数据结构
typescript
// 国家信息接口
interface CountryInfo {
code: string; // 国家代码(如 "US", "CN")
name: string; // 中文名称
nameEn: string; // 英文名称
available: boolean; // 是否可选
coordinates?: [number, number]; // 地理坐标
}
// 活动信息接口(Mock 数据)
interface ActivityInfo {
rowId: string;
activityName: string;
country: string;
lastUpdateDate: string;
}
1.2 Mock 数据示例
typescript
// Mock 国家选项数据
const mockCountryOptions = [
{
value: 'US',
territoryCode: 'US',
territoryShortName: '美国',
territoryShortNameEn: 'United States',
},
{
value: 'CN',
territoryCode: 'CN',
territoryShortName: '中国',
territoryShortNameEn: 'China',
},
{
value: 'GB',
territoryCode: 'GB',
territoryShortName: '英国',
territoryShortNameEn: 'United Kingdom',
},
// ... 更多国家
];
// Mock 活动数据
const mockActivityList = [
{
rowId: 'activity-001',
activityName: 'Summer Campaign 2024',
country: 'US',
lastUpdateDate: '2024-01-15',
},
{
rowId: 'activity-002',
activityName: 'Spring Festival Event',
country: 'CN',
lastUpdateDate: '2024-01-20',
},
// ... 更多活动
];
二、地图初始化
2.1 注册地图数据
ECharts 需要先注册地图数据才能使用。我们使用 @amcharts/amcharts5-geodata 提供的世界地图数据:
typescript
import * as echarts from 'echarts';
import worldLow from '@amcharts/amcharts5-geodata/worldLow';
// 注册世界地图
echarts.registerMap('world', worldLow, {
nameProperty: 'id', // 使用 id 作为国家标识符
});
关键点说明:
nameProperty: 'id'指定使用 GeoJSON 中的id字段作为国家标识worldLow是低精度地图数据,文件体积小,适合 Web 应用- 国家 ID 使用 ISO 3166-1 alpha-2 标准(如 US、CN、GB)
2.2 创建图表实例
typescript
const chartRef = ref<HTMLElement>();
let chartInstance: echarts.ECharts | null = null;
const initChart = async () => {
if (!chartRef.value) return;
try {
loading.value = true;
// 注册地图
echarts.registerMap('world', worldLow, {
nameProperty: 'id',
});
// 创建实例
chartInstance = echarts.init(chartRef.value);
// 设置配置项
const option = {
// ... 配置内容
};
chartInstance.setOption(option);
// 绑定事件
chartInstance.on('click', handleMapClick);
chartInstance.on('mouseover', handleMouseOver);
chartInstance.on('mouseout', handleMouseOut);
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
} catch (error) {
console.error('地图初始化失败:', error);
} finally {
loading.value = false;
}
};
三、国家区域渲染
3.1 数据处理逻辑
根据业务需求,我们需要将国家分为三种状态:
- 已选择 - 用户已选择的国家
- 可选择 - 有活动的国家
- 不可选 - 没有活动的国家
typescript
// 计算可用国家映射表
const availableCountries = computed(() => {
const map: Record<string, CountryInfo> = {};
// 获取所有有活动的国家编码
const availableCodes = new Set(mockActivityList.map((item) => item.country));
mockCountryOptions.forEach((item) => {
const key = item.territoryCode;
if (key) {
map[key] = {
code: item.value,
name: item.territoryShortName,
nameEn: item.territoryShortNameEn,
available: availableCodes.has(item.value),
};
}
});
return map;
});
3.2 生成地图区域数据
typescript
const generateMapData = () => {
const mapData: Array<{
name: string;
value: number;
selected: boolean;
itemStyle: {
areaColor: string;
borderColor: string;
borderWidth: number;
};
}> = [];
Object.entries(availableCountries.value).forEach(([territoryCode, countryInfo]) => {
const isSelected = selectedCountry.value?.code === countryInfo.code;
let value: number;
let areaColor: string;
// 根据状态设置颜色
if (isSelected) {
value = 2;
areaColor = '#72B1FF'; // 已选择:深蓝色
} else if (countryInfo.available) {
value = 1;
areaColor = '#BCDAFF'; // 可选择:浅蓝色
} else {
value = 0;
areaColor = '#D5DFED'; // 不可选:灰色
}
mapData.push({
name: territoryCode, // 使用 territoryCode(如 "US")
value,
selected: isSelected,
itemStyle: {
areaColor,
borderColor: '#fff',
borderWidth: 0.5,
},
});
});
return mapData;
};
3.3 地图配置项
typescript
const option = {
tooltip: {
trigger: 'item',
formatter: (params) => {
// 散点图不显示 tooltip
if (params.seriesType === 'effectScatter') {
return null;
}
// 获取活动名称
const activity = mockActivityList.find((item) => item.country === params.name);
if (activity) {
return `<div style="
padding: 8px 12px;
background: #fff;
border: 2px solid #1890ff;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
font-size: 14px;
color: #333;
font-weight: 500;
">活动名称: ${activity.activityName}</div>`;
}
return null;
},
backgroundColor: 'transparent',
borderWidth: 0,
padding: 0,
},
geo: {
map: 'world',
roam: true, // 允许缩放和平移
scaleLimit: {
min: 0.8,
max: 50,
},
nameProperty: 'id',
zoom: 1.2,
center: [0, 20], // 地图中心点
zlevel: 0,
itemStyle: {
borderColor: '#fff',
borderWidth: 0.5,
},
label: {
show: true,
color: '#333',
fontSize: 10,
offset: [0, -15], // 向上偏移,避免被散点遮挡
formatter: (params) => {
const countryInfo = availableCountries.value[params.name];
if (countryInfo && countryInfo.available) {
// 根据语言显示国家名称
return countryInfo.name; // 或 countryInfo.nameEn
}
return '';
},
},
emphasis: {
itemStyle: {
areaColor: '#72B1FF',
borderColor: '#ffffff',
borderWidth: 2,
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.12)',
},
label: {
show: true,
color: '#000000',
fontWeight: 'bold',
fontSize: 12,
},
},
select: {
itemStyle: {
areaColor: '#1890ff',
borderColor: '#096dd9',
borderWidth: 2,
},
label: {
show: true,
color: '#fff',
fontWeight: 'bold',
fontSize: 12,
},
},
regions: generateMapData(), // 设置区域数据
},
series: [
// 散点图系列(下一节详细介绍)
],
};
配置项关键说明:
| 配置项 | 说明 |
|---|---|
roam: true |
允许用户缩放和拖拽地图 |
scaleLimit |
限制缩放范围,防止过度缩放 |
nameProperty: 'id' |
指定使用 GeoJSON 的 id 字段匹配国家 |
regions |
设置各区域的样式和数据 |
emphasis |
鼠标悬停时的样式 |
select |
选中时的样式 |
四、散点特效实现
4.1 国家坐标映射表
为了在地图上精确标记国家位置,我们需要维护一个国家中心点坐标映射表:
typescript
// 国家地理中心点坐标映射(使用 territoryCode 作为 key)
const countryCoordinates: Record<string, [number, number]> = {
// 亚洲
AF: [67.7099, 33.9391], // Afghanistan
CN: [104.1954, 35.8617], // China
IN: [78.9629, 20.5937], // India
JP: [138.2529, 36.2048], // Japan
KR: [127.7669, 35.9078], // South Korea
// 欧洲
GB: [-3.436, 55.3781], // United Kingdom
FR: [2.2137, 46.2276], // France
DE: [10.4515, 51.1657], // Germany
IT: [12.5674, 41.8719], // Italy
ES: [-3.7492, 40.4637], // Spain
// 北美洲
US: [-95.7129, 37.0902], // United States
CA: [-106.3468, 56.1304], // Canada
MX: [-102.5528, 23.6345], // Mexico
// 南美洲
BR: [-51.9253, -14.235], // Brazil
AR: [-63.6167, -38.4161], // Argentina
// 大洋洲
AU: [133.7751, -25.2744], // Australia
NZ: [174.886, -40.9006], // New Zealand
// ... 更多国家坐标
};
坐标格式说明:
- 格式:
[经度, 纬度] - 经度范围:-180 到 180(西经为负,东经为正)
- 纬度范围:-90 到 90(南纬为负,北纬为正)
4.2 生成散点数据
typescript
const generateScatterData = () => {
const scatterData: Array<{
name: string;
value: [number, number];
}> = [];
// 遍历所有可用国家
Object.entries(availableCountries.value).forEach(([territoryCode, countryInfo]) => {
// 只为可选且未被选中的国家添加标识
if (countryInfo.available && selectedCountry.value?.code !== countryInfo.code) {
const coordinates = countryCoordinates[territoryCode];
if (coordinates) {
scatterData.push({
name: territoryCode,
value: coordinates, // [经度, 纬度]
});
}
}
});
return scatterData;
};
数据格式示例:
javascript
[
{ name: 'US', value: [-95.7129, 37.0902] },
{ name: 'CN', value: [104.1954, 35.8617] },
{ name: 'GB', value: [-3.436, 55.3781] },
// ...
];
4.3 散点图系列配置
typescript
series: [
{
name: '可选国家标识',
type: 'effectScatter', // 涟漪特效散点图
coordinateSystem: 'geo', // 使用地理坐标系
rippleEffect: {
brushType: 'stroke', // 涟漪效果类型
scale: 3, // 涟漪动画的缩放比例
period: 4, // 动画周期(秒)
},
symbol: 'circle', // 散点形状
symbolSize: 12, // 散点大小
itemStyle: {
color: '#1890ff', // 散点颜色
shadowBlur: 10,
shadowColor: 'rgba(24, 144, 255, 0.5)',
},
data: generateScatterData(),
zlevel: 0,
z: 1, // 层级(确保在地图上方)
label: {
show: false, // 不显示标签
},
},
];
效果说明:
effectScatter类型会产生涟漪动画效果rippleEffect控制涟漪的样式和速度zlevel和z控制图层顺序,确保散点在地图上方
4.4 散点与地图的层级关系
┌─────────────────────────────────┐
│ 散点图层 (z: 1, zlevel: 0) │ ← 最上层,显示涟漪特效
├─────────────────────────────────┤
│ 地图区域 (zlevel: 0) │ ← 中间层,显示国家区域
├─────────────────────────────────┤
│ 背景层 │ ← 底层
└─────────────────────────────────┘
五、交互功能实现
5.1 地图点击事件
typescript
const handleMapClick = (params: { name: string }) => {
// params.name 是 territoryCode(如 "US")
const countryInfo = availableCountries.value[params.name];
if (!countryInfo || !countryInfo.available) {
// 不可选的国家,忽略点击
return;
}
// 检查是否已选择
const isSelected = selectedCountry.value?.code === countryInfo.code;
if (isSelected) {
// 已选择,取消选择
handleClearSelection();
} else {
// 未选择,聚焦到该国家
focusOnCountry(params.name);
currentCountry.value = countryInfo;
// 触发业务逻辑(如显示弹窗)
}
};
5.2 地图聚焦功能
typescript
const focusOnCountry = (territoryCode: string) => {
if (!chartInstance) return;
const coordinates = countryCoordinates[territoryCode];
if (coordinates) {
// 设置地图中心点和缩放级别
chartInstance.setOption({
geo: {
center: coordinates,
zoom: 3, // 放大到合适的级别
},
});
}
};
// 重置地图视图
const resetMapView = () => {
if (chartInstance) {
chartInstance.setOption({
geo: {
center: [0, 20],
zoom: 1.2,
},
});
}
};
5.3 鼠标悬停效果
typescript
// 鼠标悬停事件
chartInstance.on('mouseover', (params: { name: string }) => {
const countryInfo = availableCountries.value[params.name];
if (countryInfo && countryInfo.available) {
// 可选国家,改变鼠标样式
if (chartRef.value?.style) {
chartRef.value.style.cursor = 'pointer';
}
} else if (chartRef.value?.style) {
chartRef.value.style.cursor = 'default';
}
});
// 鼠标移出事件
chartInstance.on('mouseout', () => {
if (chartRef.value?.style) {
chartRef.value.style.cursor = 'default';
}
});
5.4 动态更新地图数据
typescript
const updateMapData = () => {
nextTick(() => {
if (chartInstance) {
const newMapData = generateMapData();
const newScatterData = generateScatterData();
chartInstance.setOption({
geo: {
regions: newMapData,
},
series: [
{
data: newScatterData,
},
],
});
}
});
};
六、响应式处理
6.1 窗口大小变化
typescript
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
// 在初始化时绑定
window.addEventListener('resize', handleResize);
// 在组件卸载时解绑
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
window.removeEventListener('resize', handleResize);
});
6.2 数据加载时机控制
typescript
const isChartInitialized = ref(false);
// 监听数据源,确保都准备好后再初始化
watch(
[activityList, countryOptions, availableCountries],
([activities, countries, available]) => {
const hasActivityList = activities && activities.length > 0;
const hasCountryOptions = countries && countries.length > 0;
const hasAvailableCountries = available && Object.keys(available).length > 0;
if (hasActivityList && hasCountryOptions && hasAvailableCountries && !isChartInitialized.value) {
isChartInitialized.value = true;
initChart();
}
},
{ immediate: true, deep: true }
);
七、样式与布局
7.1 容器样式
vue
<template>
<div class="country-selection-container">
<div class="map-container">
<div
ref="chartRef"
class="echarts-container"
></div>
<!-- 加载状态 -->
<div
v-if="loading"
class="loading-overlay"
>
<p>正在加载地图数据...</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.country-selection-container {
position: relative;
display: flex;
flex-direction: column;
height: calc(100vh - 124px);
}
.map-container {
position: relative;
flex: 1;
overflow: hidden;
background: #edf2f9;
border-radius: 8px;
}
.echarts-container {
width: 100%;
height: 100%;
min-height: 500px;
}
.loading-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
}
/* 响应式设计 */
@media (max-width: 768px) {
.country-selection-container {
padding: 10px;
}
.echarts-container {
min-height: 400px;
}
}
</style>
八、性能优化建议
8.1 地图数据优化
typescript
// 使用低精度地图数据
import worldLow from '@amcharts/amcharts5-geodata/worldLow';
// 而不是高精度数据
// import world from '@amcharts/amcharts5-geodata/world';
8.2 事件节流
typescript
import { debounce } from 'lodash-es';
const handleResize = debounce(() => {
if (chartInstance) {
chartInstance.resize();
}
}, 200);
8.3 按需加载
typescript
// 动态导入地图数据
const initChart = async () => {
const worldLow = await import('@amcharts/amcharts5-geodata/worldLow');
echarts.registerMap('world', worldLow.default, {
nameProperty: 'id',
});
// ...
};
九、常见问题与解决方案
9.1 国家名称不匹配
问题: GeoJSON 中的国家 ID 与业务数据中的国家代码不一致。
解决方案:
typescript
// 使用 nameProperty 指定匹配字段
echarts.registerMap('world', worldLow, {
nameProperty: 'id', // 使用 id 字段
});
// 确保数据中的 name 字段与 GeoJSON 的 id 一致
mapData.push({
name: territoryCode, // 使用 ISO 3166-1 alpha-2 代码
value: 1,
// ...
});
9.2 散点位置不准确
问题: 散点没有显示在国家中心位置。
解决方案:
typescript
// 确保坐标格式正确:[经度, 纬度]
const coordinates = [104.1954, 35.8617]; // 正确
// 而不是 [纬度, 经度]
// 确保使用正确的坐标系
series: [
{
type: 'effectScatter',
coordinateSystem: 'geo', // 必须指定
data: scatterData,
},
];
9.3 地图层级混乱
问题: 散点被地图区域遮挡。
解决方案:
typescript
geo: {
zlevel: 0,
// ...
},
series: [{
type: 'effectScatter',
zlevel: 0,
z: 1, // 确保 z 值大于地图
// ...
}]
9.4 地图不响应窗口变化
问题: 窗口大小改变时地图不自适应。
解决方案:
typescript
// 监听窗口变化
window.addEventListener('resize', () => {
chartInstance?.resize();
});
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
});
十、完整示例代码
vue
<template>
<div class="map-wrapper">
<div
ref="chartRef"
class="chart-container"
></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import * as echarts from 'echarts';
import worldLow from '@amcharts/amcharts5-geodata/worldLow';
// Mock 数据
const mockCountryOptions = [
{ value: 'US', territoryCode: 'US', territoryShortName: '美国', territoryShortNameEn: 'United States' },
{ value: 'CN', territoryCode: 'CN', territoryShortName: '中国', territoryShortNameEn: 'China' },
{ value: 'GB', territoryCode: 'GB', territoryShortName: '英国', territoryShortNameEn: 'United Kingdom' },
];
const mockActivityList = [
{ country: 'US', activityName: 'Summer Campaign' },
{ country: 'CN', activityName: 'Spring Festival' },
];
// 响应式数据
const chartRef = ref<HTMLElement>();
let chartInstance: echarts.ECharts | null = null;
// 可用国家映射
const availableCountries = computed(() => {
const map: Record<string, any> = {};
const availableCodes = new Set(mockActivityList.map((item) => item.country));
mockCountryOptions.forEach((item) => {
map[item.territoryCode] = {
code: item.value,
name: item.territoryShortName,
nameEn: item.territoryShortNameEn,
available: availableCodes.has(item.value),
};
});
return map;
});
// 国家坐标
const countryCoordinates: Record<string, [number, number]> = {
US: [-95.7129, 37.0902],
CN: [104.1954, 35.8617],
GB: [-3.436, 55.3781],
};
// 生成地图数据
const generateMapData = () => {
return Object.entries(availableCountries.value).map(([code, info]) => ({
name: code,
value: info.available ? 1 : 0,
itemStyle: {
areaColor: info.available ? '#BCDAFF' : '#D5DFED',
borderColor: '#fff',
borderWidth: 0.5,
},
}));
};
// 生成散点数据
const generateScatterData = () => {
return Object.entries(availableCountries.value)
.filter(([_, info]) => info.available)
.map(([code]) => ({
name: code,
value: countryCoordinates[code],
}))
.filter((item) => item.value);
};
// 初始化地图
const initChart = () => {
if (!chartRef.value) return;
echarts.registerMap('world', worldLow, { nameProperty: 'id' });
chartInstance = echarts.init(chartRef.value);
const option = {
geo: {
map: 'world',
roam: true,
scaleLimit: { min: 0.8, max: 50 },
zoom: 1.2,
center: [0, 20],
itemStyle: {
borderColor: '#fff',
borderWidth: 0.5,
},
regions: generateMapData(),
},
series: [
{
type: 'effectScatter',
coordinateSystem: 'geo',
rippleEffect: {
brushType: 'stroke',
scale: 3,
period: 4,
},
symbol: 'circle',
symbolSize: 12,
itemStyle: {
color: '#1890ff',
shadowBlur: 10,
shadowColor: 'rgba(24, 144, 255, 0.5)',
},
data: generateScatterData(),
z: 1,
},
],
};
chartInstance.setOption(option);
window.addEventListener('resize', handleResize);
};
const handleResize = () => {
chartInstance?.resize();
};
onMounted(() => {
initChart();
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
window.removeEventListener('resize', handleResize);
});
</script>
<style scoped>
.map-wrapper {
width: 100%;
height: 600px;
}
.chart-container {
width: 100%;
height: 100%;
}
</style>
十一、总结
本文详细介绍了使用 ECharts 实现世界地图的完整流程,包括:
✅ 地图初始化 - 注册地图数据、创建实例 ✅ 区域渲染 - 差异化显示不同状态的国家 ✅ 散点特效 - 使用
effectScatter 实现涟漪动画 ✅ 交互功能 - 点击、悬停、聚焦等交互 ✅ 性能优化 - 数据优化、事件节流、按需加载 ✅
问题解决 - 常见问题的解决方案
核心要点回顾
-
数据格式
- 地图区域数据:
{ name: territoryCode, value: number, itemStyle: {...} } - 散点数据:
{ name: string, value: [经度, 纬度] }
- 地图区域数据:
-
层级关系
- 使用
zlevel和z控制图层顺序 - 散点图的
z值应大于地图区域
- 使用
-
坐标系统
- 散点图必须指定
coordinateSystem: 'geo' - 坐标格式:
[经度, 纬度]
- 散点图必须指定
-
性能优化
- 使用低精度地图数据
- 事件处理使用节流
- 按需加载地图数据