import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useMapStore, selectIsLoaded, selectLoadError, selectGoogle } from '../state'; import { GOOGLE_MAP_VECTOR_MAP_ID, OFFLINE_GOOGLE_MAP_VECTOR_MAP_ID } from '../constants'; import { hasGoogleMapsApiKey } from '../utils/googleMapsConfig'; interface GoogleMapProps { center: { lat: number; lng: number }; zoom: number; className?: string; onMapLoad?: (map: google.maps.Map) => void; onClick?: (e: google.maps.MapMouseEvent) => void; onLongPress?: (e: google.maps.MapMouseEvent) => void; onIdle?: (map: google.maps.Map) => void; options?: google.maps.MapOptions; } const GOOGLE_MAP_ERROR_TEXTS = [ '糟糕!出了点问题。', '此页面未能正确加载 Google 地图', '您是否拥有此网站', "This page can't load Google Maps correctly", 'Do you own this website', ]; const GOOGLE_MAP_DEV_WATERMARK_TEXT = 'For development purposes only'; function suppressOfflineGoogleErrorOverlay(root: HTMLElement) { root.dataset.mapOfflineGoogleOverlaySuppressed = 'true'; const style = document.createElement('style'); style.textContent = ` [data-map-offline-google-overlay-suppressed="true"] .gm-err-container, [data-map-offline-google-overlay-suppressed="true"] .gm-err-autocomplete { display: none !important; } `; root.appendChild(style); const hideElement = (el: HTMLElement) => { el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); }; const hideOverlay = () => { root .querySelectorAll('.gm-err-container, .gm-err-autocomplete') .forEach(hideElement); const rootRect = root.getBoundingClientRect(); root.querySelectorAll('div, button').forEach((el) => { const text = el.textContent || ''; if (GOOGLE_MAP_ERROR_TEXTS.some((item) => text.includes(item))) { hideElement(el); return; } const computed = window.getComputedStyle(el); const bg = computed.backgroundColor.replace(/\s+/g, ''); const zIndex = Number.parseInt(computed.zIndex || '0', 10); const rect = el.getBoundingClientRect(); const coversMap = rootRect.width > 0 && rootRect.height > 0 && rect.width >= rootRect.width * 0.8 && rect.height >= rootRect.height * 0.8; const isDevDimmer = computed.position === 'absolute' && coversMap && Number.isFinite(zIndex) && zIndex >= 10 && bg.startsWith('rgba(0,0,0,') && bg !== 'rgba(0,0,0,0)'; if (isDevDimmer) { hideElement(el); } }); root.querySelectorAll('span, div').forEach((el) => { if ((el.textContent || '').trim() !== GOOGLE_MAP_DEV_WATERMARK_TEXT) return; hideElement(el); }); }; hideOverlay(); const observer = new MutationObserver(hideOverlay); observer.observe(root, { childList: true, subtree: true, characterData: true }); return () => { observer.disconnect(); style.remove(); delete root.dataset.mapOfflineGoogleOverlaySuppressed; }; } function readCssZoom(el: Element): number { const raw = window.getComputedStyle(el).zoom; if (!raw || raw === 'normal') return 1; const value = Number.parseFloat(raw); return Number.isFinite(value) && value > 0 ? value : 1; } function getAncestorVisualScale(el: HTMLElement): number { let scale = 1; for (let cur = el.parentElement; cur && cur !== document.body; cur = cur.parentElement) { scale *= readCssZoom(cur); } const simScaleRaw = document.documentElement.style.getPropertyValue('--sim-scale'); const simScale = Number.parseFloat(simScaleRaw || '1'); if (Number.isFinite(simScale) && simScale > 0) { scale *= simScale; } return Number.isFinite(scale) && scale > 0 ? scale : 1; } export const GoogleMap: React.FC = ({ center, zoom, className, onMapLoad, onClick, onLongPress, onIdle, options, }) => { const isLoaded = useMapStore(selectIsLoaded); const loadError = useMapStore(selectLoadError); const google = useMapStore(selectGoogle); const wrapperRef = useRef(null); const mapRef = useRef(null); const [mapInstance, setMapInstance] = useState(null); const longPressTimerRef = useRef(null); const isDraggingRef = useRef(false); const onClickRef = useRef(onClick); const onLongPressRef = useRef(onLongPress); const onIdleRef = useRef(onIdle); useEffect(() => { onClickRef.current = onClick; onLongPressRef.current = onLongPress; onIdleRef.current = onIdle; }, [onClick, onLongPress, onIdle]); const applyMapVisualSize = useCallback(() => { const wrapper = wrapperRef.current; const mapDiv = mapRef.current; if (!wrapper || !mapDiv) return false; const layoutWidth = wrapper.clientWidth; const layoutHeight = wrapper.clientHeight; if (layoutWidth <= 0 || layoutHeight <= 0) return false; const scale = getAncestorVisualScale(wrapper); mapDiv.style.width = `${Math.max(1, layoutWidth * scale)}px`; mapDiv.style.height = `${Math.max(1, layoutHeight * scale)}px`; if (scale > 0 && Math.abs(scale - 1) > 0.001) { mapDiv.style.setProperty('zoom', String(1 / scale)); } else { mapDiv.style.removeProperty('zoom'); } return true; }, []); useLayoutEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) return undefined; let frame = 0; const updateSize = () => { frame = 0; const changed = applyMapVisualSize(); if (changed && mapInstance && google) { google.maps.event.trigger(mapInstance, 'resize'); } }; const scheduleUpdate = () => { if (frame) return; frame = window.requestAnimationFrame(updateSize); }; updateSize(); const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleUpdate) : null; observer?.observe(wrapper); window.addEventListener('resize', scheduleUpdate); return () => { if (frame) window.cancelAnimationFrame(frame); observer?.disconnect(); window.removeEventListener('resize', scheduleUpdate); }; }, [applyMapVisualSize, google, mapInstance]); // Initialize Map useEffect(() => { if (isLoaded && google && mapRef.current && !mapInstance) { try { applyMapVisualSize(); const hasRealApiKey = hasGoogleMapsApiKey(); const mapId = options?.mapId ?? (hasRealApiKey ? GOOGLE_MAP_VECTOR_MAP_ID : OFFLINE_GOOGLE_MAP_VECTOR_MAP_ID); const map = new google.maps.Map(mapRef.current, { center, zoom, disableDefaultUI: true, // Clean mobile look clickableIcons: true, gestureHandling: 'greedy', // Standard mobile gesture handling ...options, ...(mapId ? { mapId } : {}), renderingType: google.maps.RenderingType.VECTOR, }); setMapInstance(map); onMapLoad?.(map); // 暴露给 snapshot 脚本 (apps/Map/scripts/snapshot_map_assets.mjs) 驱动 pan/zoom // 遍历视口、采集瓦片。仅用于离线快照采集;运行时无副作用。 if (typeof window !== 'undefined') { (window as unknown as { __mapInstance?: google.maps.Map }).__mapInstance = map; } // Bind events map.addListener('click', (e: google.maps.MapMouseEvent) => { onClickRef.current?.(e); }); map.addListener('idle', () => { onIdleRef.current?.(map); }); // Long Press Implementation map.addListener('mousedown', (e: google.maps.MapMouseEvent) => { if (!onLongPressRef.current) return; isDraggingRef.current = false; longPressTimerRef.current = setTimeout(() => { if (!isDraggingRef.current) { onLongPressRef.current?.(e); } }, 800); // 800ms for long press }); map.addListener('mouseup', () => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } }); map.addListener('dragstart', () => { isDraggingRef.current = true; if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } }); // Touch events for mobile map.addListener('touchstart', (e: google.maps.MapMouseEvent) => { if (!onLongPressRef.current) return; isDraggingRef.current = false; longPressTimerRef.current = setTimeout(() => { if (!isDraggingRef.current) { onLongPressRef.current?.(e); } }, 800); }); map.addListener('touchend', () => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } }); } catch (e) { console.error("Error initializing map", e); } } }, [isLoaded, google, mapRef, mapInstance, onMapLoad, options, center, zoom, applyMapVisualSize]); useEffect(() => { if (!mapInstance || !google) return; applyMapVisualSize(); google.maps.event.trigger(mapInstance, 'resize'); }, [mapInstance, google, applyMapVisualSize]); useEffect(() => { if (!isLoaded || !google || !mapRef.current || hasGoogleMapsApiKey()) return; return suppressOfflineGoogleErrorOverlay(mapRef.current); }, [isLoaded, google]); const prevPropsRef = useRef({ center, zoom }); // Update View useEffect(() => { if (mapInstance) { const { center: prevCenter, zoom: prevZoom } = prevPropsRef.current; // Check Zoom if (prevZoom !== zoom) { mapInstance.setZoom(zoom); } // Check Center (Value comparison to avoid object reference issues) if (Math.abs(prevCenter.lat - center.lat) > 0.0000001 || Math.abs(prevCenter.lng - center.lng) > 0.0000001) { mapInstance.setCenter(center); } prevPropsRef.current = { center, zoom }; } }, [center, zoom, mapInstance]); if (loadError) { return (
Map unavailable
{loadError.message}
Check VITE_GOOGLE_MAPS_API_KEY configuration
); } if (!isLoaded) { return (
Loading Map...
); } return (
); };