1.需求背景
产品希望将使用产品的商家地理位置标记在地图2d图内及周边是否有厂商聚集影响区域显示
|----------|-------------|---------|----------------------------------------------------------------------------------------------------------------------------------|
| ID | 功能点 | 优先级 | 详细描述 / 逻辑规则 |
| A-01 | 全球底图 | P0 | 采用低资源消耗的世界地图底座,支持海量数据点位的高效渲染。 |
| A-02 | 用户散点展示 | P0 | 根据用户的 IP 归属地经纬度,在地图上转换为散点。- 绿色散点:代表健康的在线用户(在该地区有登录用户)。 |
| A-03 | 活跃聚集区逻辑 | P0 | 触发标准: 当特定行政区域(如省份/州)内,同时在线的客户端数量 ≥ 全局设定阈值(>=100人)时,该区域底部将生成一团 绿色呼吸光晕动效,表示该区域为业务高频活跃区。 |
| A-04 | 地区详情展示 | P1 | 鼠标悬浮在散点或活跃聚集区时,需弹出 浮层展示该地信息:- 地区名称:如"中国 - 广东省"或"美国 - 加利福尼亚州"。- 状态统计:当前区域内 在线用户数。 |
| A-05 | 地图缩放与平移 | P1 | 控件与操作:支持鼠标滚轮缩放,并提供右下角 [+] [-] 和复位按钮控件;支持鼠标拖拽平移地图。- 动态聚合 :为避免大规模散点重叠造成密集恐惧或性能卡顿,地图缩小时散点应按地区合并为一个大点(显示总数);地图放大时逐渐散开显示更细颗粒度的分布。 |
| A-06 | 地图图例 | P2 | 左下角需提供浮层的图例说明(绿点=在线用户,闪烁光晕=活跃聚集区),半透明磨砂质感。 |

2.实现过程
import * as echarts from 'echarts';
import { useEffect, useRef } from 'react';
import worldMap from './geo/words.json';
import { ReactComponent as Enlargement } from './icon/Enlargement.svg';
import { ReactComponent as Reduce } from './icon/Reduce.svg';
import { ReactComponent as Restore } from './icon/Restore.svg';
const MapChart = () => {
const mapRef = useRef(null);
const chartInstance = useRef(null);
// 存储当前视图状态(缩放和中心),用于按钮计算
const currentView = useRef({ zoom: 1.1, center: [0, 15] });
// 缩放阈值:大于此值显示省级点位,否则显示国家级点位
const ZOOM_THRESHOLD = 2.2;
// ========== 国家级点位数据(全球主要首都/城市) ==========
const countryPointsData = [
{ name: '中国·北京', lng: 116.40, lat: 39.90, size: 12 },
{ name: '日本·东京', lng: 139.69, lat: 35.68, size: 11 },
{ name: '印度·新德里', lng: 77.21, lat: 28.61, size: 11 },
{ name: '澳大利亚·堪培拉', lng: 149.13, lat: -35.28, size: 9 },
{ name: '俄罗斯·莫斯科', lng: 37.62, lat: 55.75, size: 10 },
{ name: '德国·柏林', lng: 13.40, lat: 52.52, size: 10 },
{ name: '法国·巴黎', lng: 2.35, lat: 48.86, size: 10 },
{ name: '英国·伦敦', lng: -0.13, lat: 51.51, size: 11 },
{ name: '美国·华盛顿', lng: -77.04, lat: 38.90, size: 12 },
{ name: '巴西·巴西利亚', lng: -47.87, lat: -15.80, size: 9 },
{ name: '埃及·开罗', lng: 31.23, lat: 30.04, size: 8 },
{ name: '南非·开普敦', lng: 18.42, lat: -33.92, size: 8 },
{ name: '加拿大·渥太华', lng: -75.70, lat: 45.42, size: 9 },
{ name: '墨西哥·墨西哥城', lng: -99.13, lat: 19.43, size: 9 },
{ name: '阿根廷·布宜诺斯艾利斯', lng: -58.38, lat: -34.60, size: 8 },
];
// ========== 省级/城市级点位数据(更细粒度,展示主要城市) ==========
const provincePointsData = [
// 中国主要城市
{ name: '上海', lng: 121.48, lat: 31.22, size: 9 },
{ name: '广州', lng: 113.27, lat: 23.13, size: 9 },
{ name: '深圳', lng: 114.06, lat: 22.55, size: 8 },
{ name: '杭州', lng: 120.15, lat: 30.28, size: 8 },
{ name: '南京', lng: 118.78, lat: 32.04, size: 8 },
{ name: '成都', lng: 104.06, lat: 30.67, size: 8 },
{ name: '武汉', lng: 114.30, lat: 30.60, size: 8 },
{ name: '西安', lng: 108.95, lat: 34.27, size: 8 },
{ name: '重庆', lng: 106.55, lat: 29.56, size: 9 },
{ name: '苏州', lng: 120.62, lat: 31.30, size: 7 },
// 美国主要城市
{ name: '纽约', lng: -74.01, lat: 40.71, size: 10 },
{ name: '洛杉矶', lng: -118.24, lat: 34.05, size: 9 },
{ name: '芝加哥', lng: -87.63, lat: 41.88, size: 9 },
{ name: '休斯顿', lng: -95.36, lat: 29.76, size: 8 },
{ name: '旧金山', lng: -122.42, lat: 37.77, size: 8 },
// 欧洲主要城市
{ name: '慕尼黑', lng: 11.58, lat: 48.14, size: 8 },
{ name: '里昂', lng: 4.83, lat: 45.76, size: 7 },
{ name: '巴塞罗那', lng: 2.17, lat: 41.39, size: 8 },
{ name: '米兰', lng: 9.19, lat: 45.46, size: 8 },
{ name: '阿姆斯特丹', lng: 4.90, lat: 52.37, size: 8 },
// 亚洲其他城市
{ name: '大阪', lng: 135.50, lat: 34.69, size: 8 },
{ name: '釜山', lng: 129.07, lat: 35.18, size: 7 },
{ name: '曼谷', lng: 100.50, lat: 13.73, size: 8 },
{ name: '新加坡', lng: 103.85, lat: 1.29, size: 8 },
// 澳洲城市
{ name: '悉尼', lng: 151.21, lat: -33.87, size: 8 },
{ name: '墨尔本', lng: 144.96, lat: -37.81, size: 8 },
// 南美城市
{ name: '圣保罗', lng: -46.63, lat: -23.55, size: 8 },
{ name: '里约热内卢', lng: -43.20, lat: -22.91, size: 8 },
];
// 活跃聚集区数据(保持不变)
const clusterData = [
{ center: [107, 30], radius: 11 },
{ center: [-100, 38], radius: 12 },
{ center: [15, 48], radius: 9 },
];
// 根据缩放级别更新点位数据
const updatePointsByZoom = (zoom) => {
if (!chartInstance.current) return;
// 根据阈值选择数据集
const isDetailed = zoom > ZOOM_THRESHOLD;
const pointsToUse = isDetailed ? provincePointsData : countryPointsData;
// 转换数据格式为 echarts scatter 所需
const newSeriesData = pointsToUse.map((i) => ({
name: i.name,
value: [i.lng, i.lat, i.size],
}));
// 更新 series 中 '在线用户' 的数据(合并模式,不影响其他系列)
chartInstance.current.setOption(
{
series: [
{
name: '在线用户',
data: newSeriesData,
},
],
},
{ notMerge: false }
);
};
// 更新地图的缩放和中心,并同步 ref
const updateGeoView = (zoom, center) => {
if (!chartInstance.current) return;
chartInstance.current.setOption(
{
geo: {
zoom,
center,
},
},
{ notMerge: false }
);
// 同步到 ref
currentView.current = { zoom, center };
// 缩放级别变化后,更新点位数据
updatePointsByZoom(zoom);
};
// 放大
const handleZoomIn = () => {
const { zoom, center } = currentView.current;
const newZoom = Math.min(zoom * 1.4, 10);
updateGeoView(newZoom, center);
};
// 缩小
const handleZoomOut = () => {
const { zoom, center } = currentView.current;
const newZoom = Math.max(zoom * 0.8, 0.5);
updateGeoView(newZoom, center);
};
// 还原
const handleReset = () => {
updateGeoView(1.1, [0, 15]);
};
// 监听 georoam 事件,同步用户拖拽/缩放后的状态到 ref,并更新点位
const bindRoamEvent = () => {
if (!chartInstance.current) return;
chartInstance.current.off('georoam');
chartInstance.current.on('georoam', () => {
// ✅ 正确获取当前真实的 zoom 和 center
const option = chartInstance.current.getOption();
const currentZoom = option.geo[0].zoom;
const currentCenter = option.geo[0].center;
// 同步到 ref
currentView.current = {
zoom: currentZoom,
center: currentCenter,
};
// ✅ 根据真实缩放切换点位
updatePointsByZoom(currentZoom);
});
};
// 初始化图表
const initChart = () => {
if (!chartInstance.current) return;
echarts.registerMap('world', worldMap);
// 活跃聚集区数据处理
const clusterPoints = clusterData.map((item) => ({
name: '活跃聚集区',
value: [...item.center, 0],
symbolSize: item.radius * 5,
}));
// 初始使用国家级点位数据
const initialPoints = countryPointsData.map((i) => ({
name: i.name,
value: [i.lng, i.lat, i.size],
}));
const option = {
backgroundColor: '#ffffff',
tooltip: {
trigger: 'item',
formatter: (params) => {
if (params.seriesName === '在线用户' && params.name) return params.name;
if (params.seriesName === '活跃聚集区') return '活跃聚集区';
return params.name || '';
},
backgroundColor: 'rgba(0,0,0,0.6)',
textStyle: { color: '#fff' },
},
geo: {
map: 'world',
roam: true,
zoom: 1.1,
center: [0, 15],
itemStyle: {
areaColor: '#f0f0f0',
borderColor: '#fff',
borderWidth: 1,
},
silent: true,
emphasis: { disabled: true },
},
series: [
{
name: '活跃聚集区',
type: 'effectScatter',
coordinateSystem: 'geo',
data: clusterPoints,
showEffectOn: 'render',
rippleEffect: {
brushType: 'stroke',
scale: 3,
period: 3,
},
itemStyle: {
color: 'rgba(147,250,140,0.45)',
},
symbolSize: (val) => val.symbolSize || 10,
zlevel: 1,
silent: true,
},
{
name: '在线用户',
type: 'scatter',
coordinateSystem: 'geo',
symbolSize: (val) => (Array.isArray(val) && val.length >= 3 ? val[2] : 8),
itemStyle: {
color: '#24ca25',
opacity: 1,
borderColor: '#ffffff',
borderWidth: 1,
},
zlevel: 2,
data: initialPoints,
},
],
legend: {
left: 15,
bottom: 15,
itemWidth: 14,
itemHeight: 14,
textStyle: { fontSize: 13, color: '#666' },
data: [
{ name: '在线用户', icon: 'rect' },
{ name: '活跃聚集区', icon: 'rect' },
],
},
};
chartInstance.current.setOption(option, true);
// 初始化后同步一次 ref 状态
const geoModel = chartInstance.current.getModel().getComponent('geo');
if (geoModel && typeof geoModel.getZoom === 'function') {
currentView.current = {
zoom: geoModel.getZoom(),
center: geoModel.getCenter(),
};
}
};
useEffect(() => {
if (!mapRef.current) return;
const chart = echarts.init(mapRef.current);
chartInstance.current = chart;
initChart();
bindRoamEvent();
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.dispose();
chartInstance.current = null;
};
}, []);
return (
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
<div ref={mapRef} style={{ width: '100%', height: '100%' }} />
{/* 自定义按钮组 */}
<div
style={{
position: 'absolute',
right: 10,
bottom: 20,
display: 'flex',
flexDirection: 'column',
border: '1px solid #DAE0F6',
borderRadius: 5,
backgroundColor: '#ffffff',
overflow: 'hidden',
boxShadow: 'none',
}}
>
<button
onClick={handleZoomIn}
style={{
width: 40,
height: 40,
border: 'none',
borderBottom: '1px solid #f0f2f5',
backgroundColor: '#fff',
cursor: 'pointer',
fontSize: 20,
color: '#86909c',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
padding: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f8f9fa'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff'; }}
title="放大"
>
<Enlargement />
</button>
<button
onClick={handleReset}
style={{
width: 40,
height: 40,
border: 'none',
borderBottom: '1px solid #f0f2f5',
backgroundColor: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
padding: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f8f9fa'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff'; }}
title="还原"
>
<Restore />
</button>
<button
onClick={handleZoomOut}
style={{
width: 40,
height: 40,
border: 'none',
backgroundColor: '#fff',
cursor: 'pointer',
fontSize: 20,
color: '#86909c',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
padding: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f8f9fa'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff'; }}
title="缩小"
>
<Reduce />
</button>
</div>
</div>
);
};
export default MapChart;