react+echarts实现2d地图标记点与影响区域及可拖拽放大缩小等功能

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;
相关推荐
哈__2 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-haptic-feedback
javascript·react native·react.js
周淳APP3 小时前
【React Hook全家桶】大致过一遍React Hooks
前端·javascript·react.js·前端框架·react hooks
英俊潇洒美少年4 小时前
react 18 的fiber算法
前端·算法·react.js
游戏开发爱好者84 小时前
入门 iOS 开发 新手工具开发首个应用
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
吃西瓜的年年4 小时前
react(一)
前端·react.js·前端框架
每天都要进步哦4 小时前
React入门和快速上手
前端·vue.js·react.js·react
早點睡39013 小时前
ReactNative项目OpenHarmony三方库集成实战:react-native-swiper
javascript·react native·react.js
问道飞鱼14 小时前
【前端知识】React 组件生命周期:从底层原理到实践场景
前端·react.js·前端框架·生命周期
LZQ <=小氣鬼=>17 小时前
React 图片放大镜组件使用文档
javascript·react.js·前端框架·ecmascript