components_amap.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import React, { useEffect, useRef } from 'react';
  2. import { useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { Spin } from 'antd';
  4. import './style_amap.css';
  5. import { MapMode, MarkerData } from '../share/types.ts';
  6. // 在线地图配置
  7. export const AMAP_ONLINE_CONFIG = {
  8. // 高德地图 Web API 密钥
  9. API_KEY: window.CONFIG?.MAP_CONFIG?.KEY,
  10. // 主JS文件路径
  11. MAIN_JS: 'https://webapi.amap.com/maps?v=2.0&key=' + window.CONFIG?.MAP_CONFIG?.KEY,
  12. // 插件列表
  13. PLUGINS: ['AMap.MouseTool', 'AMap.RangingTool', 'AMap.Scale', 'AMap.ToolBar', 'AMap.MarkerCluster'],
  14. };
  15. export const AMAP_OFFLINE_CONFIG = {
  16. // 主JS文件路径
  17. MAIN_JS: '/amap/amap3.js?v=2.0',
  18. // 插件目录
  19. PLUGINS_PATH: '/amap/plugins',
  20. // 插件列表
  21. PLUGINS: ['AMap.MouseTool', 'AMap.RangingTool', 'AMap.Scale', 'AMap.ToolBar', 'AMap.MarkerCluster'],
  22. };
  23. // 离线瓦片配置
  24. export const TILE_CONFIG = {
  25. // 瓦片地图基础路径
  26. BASE_URL: '/amap/tiles',
  27. // 缩放级别范围
  28. ZOOMS: [3, 20] as [number, number],
  29. // 默认中心点
  30. DEFAULT_CENTER: [108.25910334, 27.94292459] as [number, number],
  31. // 默认缩放级别
  32. DEFAULT_ZOOM: 15
  33. } as const;
  34. // 地图控件配置
  35. export const MAP_CONTROLS = {
  36. scale: true,
  37. toolbar: true,
  38. mousePosition: true,
  39. } as const;
  40. export interface AMapProps {
  41. style?: React.CSSProperties;
  42. width?: string | number;
  43. height?: string | number;
  44. center?: [number, number];
  45. zoom?: number;
  46. mode?: MapMode;
  47. onMarkerClick?: (markerData: MarkerData) => void;
  48. onClick?: (lnglat: [number, number]) => void;
  49. markers?: MarkerData[];
  50. showCluster?: boolean;
  51. queryKey?: string;
  52. }
  53. export interface MapConfig {
  54. zoom: number;
  55. center: [number, number];
  56. zooms: [number, number];
  57. resizeEnable: boolean;
  58. rotateEnable: boolean;
  59. pitchEnable: boolean;
  60. defaultCursor: string;
  61. showLabel: boolean;
  62. layers?: any[];
  63. }
  64. export interface AMapInstance {
  65. map: any;
  66. setZoomAndCenter: (zoom: number, center: [number, number]) => void;
  67. setCenter: (center: [number, number]) => void;
  68. setZoom: (zoom: number) => void;
  69. destroy: () => void;
  70. clearMap: () => void;
  71. getAllOverlays: (type: string) => any[];
  72. on: (event: string, handler: Function) => void;
  73. }
  74. declare global {
  75. interface Window {
  76. AMap: any;
  77. }
  78. }
  79. const loadScript = (url: string,plugins:string[]): Promise<void> => {
  80. return new Promise((resolve, reject) => {
  81. const script = document.createElement('script');
  82. script.type = 'text/javascript';
  83. script.src = url + (plugins.length > 0 ? `&plugin=${plugins.join(',')}` : '');
  84. script.onerror = (e) => reject(e);
  85. script.onload = () => resolve();
  86. document.head.appendChild(script);
  87. });
  88. };
  89. export const useAMapLoader = (mode: MapMode = MapMode.ONLINE) => {
  90. return useQuery({
  91. queryKey: ['amap-loader', mode],
  92. queryFn: async () => {
  93. if (typeof window === 'undefined') return null;
  94. if (!window.AMap) {
  95. const config = mode === MapMode.OFFLINE ? AMAP_OFFLINE_CONFIG : AMAP_ONLINE_CONFIG;
  96. await loadScript(config.MAIN_JS,config.PLUGINS);
  97. }
  98. return window.AMap;
  99. },
  100. staleTime: Infinity, // 地图脚本加载后永不过期
  101. gcTime: Infinity,
  102. retry: 2,
  103. });
  104. };
  105. export const useAMapClick = (
  106. map: any,
  107. onClick?: (lnglat: [number, number]) => void
  108. ) => {
  109. const mouseTool = useRef<any>(null);
  110. const clickHandlerRef = useRef<((e: any) => void) | null>(null);
  111. useEffect(() => {
  112. if (!map) return;
  113. // 清理旧的点击处理器
  114. if (clickHandlerRef.current) {
  115. map.off('click', clickHandlerRef.current);
  116. clickHandlerRef.current = null;
  117. }
  118. // 如果有点击回调,设置新的点击处理器
  119. if (onClick) {
  120. clickHandlerRef.current = (e: any) => {
  121. const lnglat = e.lnglat.getLng ?
  122. [e.lnglat.getLng(), e.lnglat.getLat()] as [number, number] :
  123. [e.lnglat.lng, e.lnglat.lat] as [number, number];
  124. onClick(lnglat);
  125. };
  126. map.on('click', clickHandlerRef.current);
  127. }
  128. return () => {
  129. if (clickHandlerRef.current) {
  130. map.off('click', clickHandlerRef.current);
  131. clickHandlerRef.current = null;
  132. }
  133. };
  134. }, [map, onClick]);
  135. return {
  136. mouseTool: mouseTool.current
  137. };
  138. };
  139. // 定义图标配置的类型
  140. interface MarkerIconConfig {
  141. size: [number, number];
  142. content: string;
  143. }
  144. // 默认图标配置
  145. const DEFAULT_MARKER_ICON: MarkerIconConfig = {
  146. size: [25, 34],
  147. content: `
  148. <svg width="25" height="34" viewBox="0 0 25 34" fill="none" xmlns="http://www.w3.org/2000/svg">
  149. <path d="M12.5 0C5.59644 0 0 5.59644 0 12.5C0 21.875 12.5 34 12.5 34C12.5 34 25 21.875 25 12.5C25 5.59644 19.4036 0 12.5 0ZM12.5 17C10.0147 17 8 14.9853 8 12.5C8 10.0147 10.0147 8 12.5 8C14.9853 8 17 10.0147 17 12.5C17 14.9853 14.9853 17 12.5 17Z" fill="#1890ff"/>
  150. </svg>
  151. `
  152. };
  153. interface UseAMapMarkersProps {
  154. map: any;
  155. markers: MarkerData[];
  156. showCluster?: boolean;
  157. onMarkerClick?: (markerData: MarkerData) => void;
  158. }
  159. export const useAMapMarkers = ({
  160. map,
  161. markers,
  162. showCluster = true,
  163. onMarkerClick,
  164. }: UseAMapMarkersProps) => {
  165. const clusterInstance = useRef<any>(null);
  166. const markersRef = useRef<any[]>([]);
  167. // 优化经纬度格式化函数
  168. const toFixedDigit = (num: number, n: number): string => {
  169. if (typeof num !== "number") return "";
  170. return Number(num).toFixed(n);
  171. };
  172. // 创建标记点
  173. const createMarker = (markerData: MarkerData) => {
  174. const { longitude, latitude, title, iconUrl } = markerData;
  175. // 创建标记点
  176. const marker = new window.AMap.Marker({
  177. position: [longitude, latitude],
  178. title: title,
  179. icon: iconUrl ? new window.AMap.Icon({
  180. size: DEFAULT_MARKER_ICON.size,
  181. imageSize: DEFAULT_MARKER_ICON.size,
  182. image: iconUrl
  183. }) : new window.AMap.Icon({
  184. size: DEFAULT_MARKER_ICON.size,
  185. imageSize: DEFAULT_MARKER_ICON.size,
  186. image: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(DEFAULT_MARKER_ICON.content)}`
  187. }),
  188. label: title ? {
  189. content: title,
  190. direction: 'top'
  191. } : undefined
  192. });
  193. // 添加点击事件
  194. if (onMarkerClick) {
  195. marker.on('click', () => onMarkerClick(markerData));
  196. }
  197. return marker;
  198. };
  199. // 处理聚合点
  200. const handleCluster = () => {
  201. if (!map || !markers.length) return;
  202. const points = markers.map(item => ({
  203. weight: 1,
  204. lnglat: [
  205. toFixedDigit(item.longitude, 5),
  206. toFixedDigit(item.latitude, 5)
  207. ],
  208. ...item
  209. }));
  210. if (clusterInstance.current) {
  211. clusterInstance.current.setData(points);
  212. return;
  213. }
  214. if(window.AMap?.MarkerCluster){
  215. clusterInstance.current = new window.AMap.MarkerCluster(map, points, {
  216. gridSize: 60,
  217. renderMarker: (context: { marker: any; data: MarkerData[] }) => {
  218. const { marker, data } = context;
  219. const firstPoint = data[0];
  220. if (firstPoint.iconUrl) {
  221. marker.setContent(`<img src="${firstPoint.iconUrl}" style="width:${DEFAULT_MARKER_ICON.size[0]}px;height:${DEFAULT_MARKER_ICON.size[1]}px;">`);
  222. } else {
  223. marker.setContent(DEFAULT_MARKER_ICON.content);
  224. }
  225. marker.setAnchor('bottom-center');
  226. marker.setOffset(new window.AMap.Pixel(0, 0));
  227. if (firstPoint.title) {
  228. marker.setLabel({
  229. direction: 'top',
  230. offset: new window.AMap.Pixel(0, -5),
  231. content: firstPoint.title
  232. });
  233. }
  234. marker.on('click', () => onMarkerClick?.(firstPoint));
  235. }
  236. });
  237. }
  238. // 优化聚合点点击逻辑
  239. if(clusterInstance.current){
  240. clusterInstance.current.on('click', (item: any) => {
  241. if (item.clusterData.length <= 1) return;
  242. const center = item.clusterData.reduce(
  243. (acc: number[], curr: any) => [
  244. acc[0] + Number(curr.lnglat[0]),
  245. acc[1] + Number(curr.lnglat[1])
  246. ],
  247. [0, 0]
  248. ).map((coord: number) => coord / item.clusterData.length);
  249. map.setZoomAndCenter(map.getZoom() + 2, center);
  250. });
  251. }
  252. };
  253. // 处理普通标记点
  254. const handleMarkers = () => {
  255. if (!map || !markers.length) return;
  256. // 清除旧的标记点
  257. markersRef.current.forEach(marker => marker.setMap(null));
  258. markersRef.current = [];
  259. // 添加新的标记点
  260. markersRef.current = markers.map(markerData => {
  261. const marker = createMarker(markerData);
  262. marker.setMap(map);
  263. return marker;
  264. });
  265. };
  266. useEffect(() => {
  267. if (!map) return;
  268. // 清理旧的标记点和聚合点
  269. if (clusterInstance.current) {
  270. clusterInstance.current.setMap(null);
  271. clusterInstance.current = null;
  272. }
  273. markersRef.current.forEach(marker => marker.setMap(null));
  274. markersRef.current = [];
  275. // 根据配置添加新的标记点
  276. if (showCluster) {
  277. handleCluster();
  278. } else {
  279. handleMarkers();
  280. }
  281. return () => {
  282. if (clusterInstance.current) {
  283. clusterInstance.current.setMap(null);
  284. clusterInstance.current = null;
  285. }
  286. markersRef.current.forEach(marker => marker.setMap(null));
  287. markersRef.current = [];
  288. };
  289. }, [map, markers, showCluster]);
  290. };
  291. const AMapComponent: React.FC<AMapProps> = ({
  292. width = '100%',
  293. height = '400px',
  294. center = TILE_CONFIG.DEFAULT_CENTER as [number, number],
  295. zoom = TILE_CONFIG.DEFAULT_ZOOM,
  296. mode = window.CONFIG?.MAP_CONFIG?.MAP_MODE || MapMode.ONLINE,
  297. onMarkerClick,
  298. onClick,
  299. markers = [],
  300. showCluster = true,
  301. queryKey = 'amap-instance',
  302. }) => {
  303. const mapContainer = useRef<HTMLDivElement>(null);
  304. const mapInstance = useRef<AMapInstance | null>(null);
  305. const queryClient = useQueryClient();
  306. // 加载地图脚本
  307. const { data: AMap, isLoading: isLoadingScript } = useAMapLoader(mode);
  308. // 初始化地图实例
  309. const { data: map } = useQuery<AMapInstance>({
  310. queryKey: [ queryKey ],
  311. queryFn: async () => {
  312. if (!AMap || !mapContainer.current) return null;
  313. const config: MapConfig = {
  314. zoom,
  315. center,
  316. zooms: [3, 20],
  317. resizeEnable: true,
  318. rotateEnable: false,
  319. pitchEnable: false,
  320. defaultCursor: 'pointer',
  321. showLabel: true,
  322. };
  323. if (mode === 'offline') {
  324. config.layers = [
  325. new AMap.TileLayer({
  326. getTileUrl: (x: number, y: number, z: number) =>
  327. `${TILE_CONFIG.BASE_URL}/${z}/${x}/${y}.png`,
  328. zIndex: 100,
  329. })
  330. ];
  331. }
  332. const newMap = new AMap.Map(mapContainer.current, config);
  333. mapInstance.current = newMap;
  334. return newMap;
  335. },
  336. enabled: !!AMap && !!mapContainer.current && !isLoadingScript,
  337. gcTime: Infinity,
  338. staleTime: Infinity,
  339. });
  340. // 处理标记点
  341. useAMapMarkers({
  342. map,
  343. markers,
  344. showCluster,
  345. onMarkerClick,
  346. });
  347. // 处理点击事件
  348. useAMapClick(map, onClick);
  349. // 更新地图视图
  350. useEffect(() => {
  351. if (!map) return;
  352. if (center && zoom) {
  353. map.setZoomAndCenter(zoom, center);
  354. } else if (center) {
  355. map.setCenter(center);
  356. } else if (zoom) {
  357. map.setZoom(zoom);
  358. }
  359. }, [map, center, zoom]);
  360. // 清理地图实例和查询缓存
  361. useEffect(() => {
  362. return () => {
  363. if (mapInstance.current) {
  364. mapInstance.current.destroy();
  365. mapInstance.current = null;
  366. // 清理 React Query 缓存
  367. queryClient.removeQueries({ queryKey: [ queryKey ] });
  368. }
  369. };
  370. }, [queryClient]);
  371. return (
  372. <div
  373. ref={mapContainer}
  374. style={{
  375. width,
  376. height,
  377. position: 'relative',
  378. }}
  379. >
  380. {isLoadingScript && <div className="w-full h-full flex justify-center items-center"><Spin /></div>}
  381. </div>
  382. );
  383. };
  384. export default AMapComponent;