import React, { useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { cn } from '../../shared/utils'; import { useGoogleMapsLoader } from '../google-maps-loader'; import { useMapLayers, MapLayersConfig } from '../map-layers'; declare global { namespace JSX { interface IntrinsicElements { 'gmp-map': React.DetailedHTMLProps, HTMLElement> & { 'map-id'?: string; center?: string | google.maps.LatLng | google.maps.LatLngLiteral; zoom?: number; }; 'gmp-advanced-marker': React.DetailedHTMLProps< React.HTMLAttributes, HTMLElement > & { title?: string; position?: string | google.maps.LatLng | google.maps.LatLngLiteral; }; } } } export interface MapProps extends React.HTMLAttributes { center?: { lat: number; lng: number }; zoom?: number; mapTypeId?: string; mapId?: string; markers?: Array<{ position: { lat: number; lng: number }; label?: string; title?: string; info?: string; customColor?: string; icon?: string; iconSvg?: string; iconColor?: string; infoWindowContent?: string; // Custom HTML content for InfoWindow richContent?: React.ReactNode; // Custom React component for InfoWindow }>; circle?: { center: { lat: number; lng: number }; radius: number; fillColor?: string; strokeColor?: string; }; polygon?: { paths: Array<{ lat: number; lng: number }>[]; fillColor?: string; strokeColor?: string; }; layers?: MapLayersConfig; height?: string; apiKey?: string; mapContainerClassName?: string; disableDefaultUI?: boolean; zoomControl?: boolean; streetViewControl?: boolean; mapTypeControl?: boolean; fullscreenControl?: boolean; gestureHandling?: 'cooperative' | 'greedy' | 'none' | 'auto'; onMapLoad?: (map: google.maps.Map) => void; } const DEFAULT_CENTER = { lat: -23.5505, lng: -46.6333 }; const DEFAULT_ZOOM = 12; const MapContent = React.forwardRef( ({ apiKey, ...props }, ref) => { const { isLoaded, loadError, load } = useGoogleMapsLoader(); const { center = DEFAULT_CENTER, zoom = DEFAULT_ZOOM, markers = [], circle, polygon, layers, height = '400px', mapContainerClassName, disableDefaultUI = false, zoomControl = true, streetViewControl = false, mapTypeControl = false, fullscreenControl = true, gestureHandling = 'cooperative', onMapLoad, className, ...divProps } = props; const [selectedMarker, setSelectedMarker] = useState(null); const mapRef = useRef(null); const gmpMapRef = useRef(null); // Ref for custom element const infoWindowRef = useRef(null); const circleRef = useRef(null); const polygonRef = useRef(null); // Resolve theme colors for Map shapes (Circle, Polygon) const [themeColors, setThemeColors] = useState({ primary: '#4F46E5', chart2: '#10B981', }); useEffect(() => { if (typeof window !== 'undefined') { const styles = getComputedStyle(document.documentElement); setThemeColors({ primary: styles.getPropertyValue('--primary').trim() || '#4F46E5', chart2: styles.getPropertyValue('--chart-2').trim() || '#10B981', }); } }, []); // Load API Key useEffect(() => { if (!isLoaded && apiKey && !loadError && load) { load(apiKey).catch(console.error); } }, [isLoaded, apiKey, loadError, load]); // Handle gmp-map initialization useEffect(() => { if (!isLoaded || !gmpMapRef.current) return; const gmpMap = gmpMapRef.current; // Access the underlying map instance if (gmpMap.innerMap) { mapRef.current = gmpMap.innerMap; if (onMapLoad) { onMapLoad(gmpMap.innerMap); } } else { // Fallback or wait for it to be ready if needed, usually available immediately after upgrade // Listener for 'gmp-map-load' doesn't exist standardly, but checking availability const interval = setInterval(() => { if (gmpMap.innerMap) { mapRef.current = gmpMap.innerMap; if (onMapLoad) { onMapLoad(gmpMap.innerMap); } clearInterval(interval); } }, 100); return () => clearInterval(interval); } return () => { mapRef.current = null; }; }, [isLoaded]); // Sync properties with gmp-map custom element useEffect(() => { if (gmpMapRef.current) { if (center) gmpMapRef.current.center = center; } }, [center]); useEffect(() => { if (gmpMapRef.current) { if (zoom !== undefined) gmpMapRef.current.zoom = zoom; } }, [zoom]); useEffect(() => { if (gmpMapRef.current) { if (props.mapTypeId) gmpMapRef.current.mapTypeId = props.mapTypeId; } }, [props.mapTypeId]); // mapId is now set declaratively on the gmp-map element // Update Circle (Imperative approach still needed for shapes as there are no gmp-circle elements yet) useEffect(() => { const map = mapRef.current; if (!map || !isLoaded) return; circleRef.current?.setMap(null); circleRef.current = null; if (circle && circle.center && circle.radius) { circleRef.current = new google.maps.Circle({ map, center: circle.center, radius: circle.radius, fillColor: circle.fillColor || themeColors.primary, fillOpacity: 0.2, strokeColor: circle.strokeColor || themeColors.primary, strokeOpacity: 0.8, strokeWeight: 2, }); } return () => { circleRef.current?.setMap(null); }; }, [circle, isLoaded, themeColors, mapRef.current]); // Added mapRef.current dependency // Update Polygon (Imperative approach still needed) useEffect(() => { const map = mapRef.current; if (!map || !isLoaded) return; polygonRef.current?.setMap(null); polygonRef.current = null; if (polygon && polygon.paths) { polygonRef.current = new google.maps.Polygon({ map, paths: polygon.paths, fillColor: polygon.fillColor || themeColors.chart2, fillOpacity: 0.2, strokeColor: polygon.strokeColor || themeColors.chart2, strokeOpacity: 0.8, strokeWeight: 2, }); } return () => { polygonRef.current?.setMap(null); }; }, [polygon, isLoaded, themeColors, mapRef.current]); // Layers useMapLayers(mapRef.current, layers || {}); // InfoWindow Management useEffect(() => { const map = mapRef.current; if (!map || selectedMarker === null) { infoWindowRef.current?.close(); return; } const markerData = markers[selectedMarker]; if (!markerData) return; // Note: We can't easily attach InfoWindow to gmp-advanced-marker via `anchor` prop in JS API // because gmp-advanced-marker is an HTMLElement, not an AdvancedMarkerElement instance directly in the same way. // However, gmp-advanced-marker has an .innerMarker property which IS the AdvancedMarkerElement. // We need references to the marker elements. // Since we are rendering them declaratively, we need to capture their refs or find them. // A simple way is to query them or use a callback ref approach in the render loop. // But here, selectedMarker is an index. // Let's defer InfoWindow opening to the click handler of the marker itself }, [selectedMarker, markers]); const handleMarkerClick = (index: number, markerElement: any) => { setSelectedMarker(index); const map = mapRef.current; if (!map) return; const markerData = markers[index]; if (!markerData) return; // Determine content to render let contentToRender = markerData.richContent; // If no richContent but title/info exists, create default standard content if (!contentToRender && (markerData.title || markerData.info)) { contentToRender = (
{markerData.title && (

{markerData.title}

)} {markerData.info &&

{markerData.info}

}
); } if (contentToRender && markerElement.innerMarker) { if (!infoWindowRef.current) { infoWindowRef.current = new google.maps.InfoWindow(); } const container = document.createElement('div'); const root = createRoot(container); root.render(contentToRender); infoWindowRef.current.setContent(container); infoWindowRef.current.open({ map, anchor: markerElement.innerMarker, }); const listener = infoWindowRef.current.addListener('closeclick', () => { setSelectedMarker(null); setTimeout(() => root.unmount(), 0); }); // Cleanup logic for previous opens is tricky with this imperativeness mixed with declarative, // but 'open' usually closes previous one if same instance. } }; if (loadError) { return (

Failed to load Google Maps

Check API key in Settings

); } if (!isLoaded) { return (
); } return (
{/* @ts-ignore - gmp-map is a custom element */} {markers.map((markerData, idx) => { const markerColor = markerData.customColor || 'var(--primary)'; const iconColor = markerData.iconColor || 'white'; // Generate icon/content // Note: gmp-advanced-marker slot content is the custom marker content return ( // @ts-ignore { if (el) { el.position = markerData.position; // Remove previous listener if any (useEffect cleanups handle component unmounts, // but for ref callback we rely on it being called with null on unmount or re-render) // However, adding listener repeatedly on re-renders might duplicate if not careful. // React ref callback is called with null then value on updates if inline function changes. // Ideally we should use a stable ref or effect, but this simple way works if we assume // the element is recreated or we accept some overhead. // Better: just add listener. standard addEventListener doesn't dedupe by default but // with arrow function it will add new one. // To be safe and simple: just set properties. // For event listener, it's safer to not add it here if it's already there? // Actually, let's just use the property 'onclick' if available? No, custom events. // We will assume the element is fresh or we don't care too much about duplicate listeners for now // AS LONG AS we don't create memory leaks. // A better pattern is to use a Map to manage listeners but let's stick to simple first. el.addEventListener('gmp-click', () => handleMarkerClick(idx, el)); } }} >
{markerData.iconSvg ? (
) : ( {markerData.icon || markerData.label || ''} )}
); })}
); } ); MapContent.displayName = 'MapContent'; /** * Primary Google Maps component. * * @description * Supports Advanced Markers, Circles, Polygons, and custom tile Layers. * Automatically loads the Google Maps JavaScript API via `useGoogleMapsLoader`. * * @ai-rules * 1. REQUIRED: Provide a valid `apiKey` via prop or the `VITE_GOOGLE_MAPS_API_KEY` environment variable. * 2. Use the `markers` prop to render points of interest. Supports `richContent` for React-based InfoWindows. * 3. Always set `height` explicitly (default is 400px) to ensure the map is visible in the layout. */ export const Map = React.forwardRef((props, ref) => { const { isLoaded, loadError } = useGoogleMapsLoader(); const effectiveApiKey = props.apiKey || (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_GOOGLE_MAPS_API_KEY) || ''; const isValidKey = effectiveApiKey && effectiveApiKey !== 'YOUR_GOOGLE_MAPS_API_KEY_HERE' && effectiveApiKey.startsWith('AIza'); if (isLoaded || isValidKey || loadError) { return ; } // Check if the script is injected in the DOM (loading via provider) const isScriptInjected = typeof document !== 'undefined' && !!document.querySelector('script[src*="maps.googleapis.com/maps/api/js"]'); if (isScriptInjected) { // Let MapContent show the loading skeleton return ; } return (

Configure Google Maps API Key in Settings

); }); Map.displayName = 'Map';