import React, { useEffect, useRef, useState, useCallback, useMemo, } from "react"; import { Loader } from "@googlemaps/js-api-loader"; import { createRoot } from "react-dom/client"; import { Item } from "../types"; import ItemMarkerInfo from "./ItemMarkerInfo"; // Debounce utility function function debounce any>( func: T, wait: number ): (...args: Parameters) => void { let timeout: NodeJS.Timeout; return (...args: Parameters) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; } interface SearchResultsMapProps { items: Item[]; searchLocation?: string; className?: string; style?: React.CSSProperties; onItemSelect?: (item: Item) => void; } const SearchResultsMap: React.FC = ({ items, searchLocation, className, style, onItemSelect, }) => { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const markersRef = useRef([]); const infoWindowRef = useRef(null); const mapInitializedRef = useRef(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY; const mapId = process.env.REACT_APP_GOOGLE_MAPS_MAP_ID; // Clean up markers const clearMarkers = useCallback(() => { markersRef.current.forEach((marker) => { marker.map = null; }); markersRef.current = []; if (infoWindowRef.current) { infoWindowRef.current.close(); } }, []); // Create marker for an item const createItemMarker = useCallback( async (item: Item, map: google.maps.Map) => { if (!item.latitude || !item.longitude) return null; const { AdvancedMarkerElement } = (await google.maps.importLibrary( "marker" )) as google.maps.MarkerLibrary; // Create marker element const isMobile = window.innerWidth < 768; const markerSize = isMobile ? 48 : 40; const markerElement = document.createElement("div"); markerElement.className = "d-flex align-items-center justify-content-center"; markerElement.style.cssText = ` width: ${markerSize}px; height: ${markerSize}px; background-color: #0d6efd; border: 3px solid white; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: transform 0.2s ease; touch-action: manipulation; `; // Add icon const icon = document.createElement("i"); icon.className = "bi bi-geo-fill text-white"; icon.style.fontSize = "16px"; markerElement.appendChild(icon); // Hover effects markerElement.addEventListener("mouseenter", () => { markerElement.style.transform = "scale(1.1)"; }); markerElement.addEventListener("mouseleave", () => { markerElement.style.transform = "scale(1)"; }); const marker = new AdvancedMarkerElement({ position: { lat: item.latitude, lng: item.longitude }, map: map, content: markerElement, title: item.name, }); // Add click listener marker.addListener("click", () => { if (infoWindowRef.current) { infoWindowRef.current.close(); } const infoWindow = new google.maps.InfoWindow({ headerDisabled: true, maxWidth: window.innerWidth < 768 ? Math.min(300, window.innerWidth - 40) : 300, }); // Create React container for info window content const infoContainer = document.createElement("div"); const root = createRoot(infoContainer); root.render( { infoWindow.close(); if (onItemSelect) { onItemSelect(item); } else { window.location.href = `/items/${item.id}`; } }} /> ); infoWindow.setContent(infoContainer); infoWindow.open(map, marker); infoWindowRef.current = infoWindow; }); return marker; }, [onItemSelect] ); // Add markers for all items const addMarkersToMap = useCallback( async (map: google.maps.Map, items: Item[]) => { clearMarkers(); const validItems = items.filter( (item) => item.latitude && item.longitude ); for (const item of validItems) { const marker = await createItemMarker(item, map); if (marker) { markersRef.current.push(marker); } } }, [createItemMarker, clearMarkers] ); // Calculate map bounds to fit all markers with appropriate zoom levels const fitMapToMarkers = useCallback((map: google.maps.Map, items: Item[]) => { const validItems = items.filter((item) => item.latitude && item.longitude); if (validItems.length === 0) return; const bounds = new google.maps.LatLngBounds(); validItems.forEach((item) => { if (item.latitude && item.longitude) { bounds.extend(new google.maps.LatLng(item.latitude, item.longitude)); } }); if (validItems.length === 1) { // Single marker - center and set neighborhood-level zoom (not street-level) map.setCenter(bounds.getCenter()); map.setZoom(10); // Changed from 12 to 10 for better context } else { // Multiple markers - calculate distance to determine zoom strategy const northeast = bounds.getNorthEast(); const southwest = bounds.getSouthWest(); // Calculate approximate distance between bounds corners in miles const distance = google.maps.geometry.spherical.computeDistanceBetween( southwest, northeast ) / 1609.34; // Convert meters to miles if (distance < 0.5) { // Very close items - show broader neighborhood context const center = bounds.getCenter(); map.setCenter(center); map.setZoom(11); // Neighborhood level for very close items } else if (distance < 5) { // Moderate spread - use fitBounds with generous padding and min zoom map.fitBounds(bounds, 120); // Increased padding from 50 to 120 // Ensure we don't zoom too close const currentZoom = map.getZoom(); if (currentZoom && currentZoom > 12) { map.setZoom(12); } } else { // Wide spread - use fitBounds with standard padding map.fitBounds(bounds, 80); // Ensure minimum zoom for very spread out items const currentZoom = map.getZoom(); if (currentZoom && currentZoom < 6) { map.setZoom(6); } } } }, []); // Check if any items have coordinates before initializing map const hasMappableItems = items.some( (item) => item.latitude && item.longitude ); // Debounced function to update map markers (prevents excessive API calls during rapid updates) const debouncedUpdateMap = useMemo( () => debounce(async (map: google.maps.Map, items: Item[]) => { try { setIsLoading(true); await addMarkersToMap(map, items); fitMapToMarkers(map, items); } catch (err) { console.error("Error updating map:", err); } finally { setIsLoading(false); } }, 300), // 300ms debounce delay [addMarkersToMap, fitMapToMarkers] ); // Initialize map useEffect(() => { if (!apiKey || !mapRef.current || !hasMappableItems) return; // If map is already initialized, just update markers using debounced function if (mapInitializedRef.current && mapInstanceRef.current) { debouncedUpdateMap(mapInstanceRef.current, items); return; } let mounted = true; const initializeMap = async () => { try { setIsLoading(true); setError(null); const loader = new Loader({ apiKey, version: "weekly", }); await loader.importLibrary("maps"); await loader.importLibrary("marker"); await loader.importLibrary("geometry"); if (!mounted || !mapRef.current) return; // Default map center (will be updated based on search results) const defaultCenter = { lat: 39.8283, lng: -98.5795 }; // Geographic center of US const mapConfig: google.maps.MapOptions = { mapId: mapId, center: defaultCenter, zoom: 4, maxZoom: 15, // Prevent users from zooming too close zoomControl: true, mapTypeControl: false, scaleControl: false, streetViewControl: false, rotateControl: false, fullscreenControl: true, mapTypeId: google.maps.MapTypeId.ROADMAP, }; const map = new google.maps.Map(mapRef.current, mapConfig); mapInstanceRef.current = map; mapInitializedRef.current = true; // Add markers if items exist if (items.length > 0) { await addMarkersToMap(map, items); fitMapToMarkers(map, items); } } catch (err) { console.error("Failed to load Google Maps:", err); if (mounted) { setError("Failed to load map. Please try again later."); } } finally { if (mounted) { setIsLoading(false); } } }; initializeMap(); return () => { mounted = false; // Don't clear markers on cleanup - preserve for next use }; }, [ apiKey, mapId, hasMappableItems, items, addMarkersToMap, fitMapToMarkers, clearMarkers, debouncedUpdateMap, ]); // This useEffect is now handled by the main initialization effect above const defaultStyle = { height: "600px", width: "100%", borderRadius: "8px", backgroundColor: "#f8f9fa", }; // Show message when no items have location data if (!hasMappableItems) { return (
No Location Data

None of the search results have location information available for mapping.

); } if (error) { return (

{error}

); } return (
{isLoading && (
Loading map...

Loading map...

)}
{!isLoading && items.length > 0 && (
{ items.filter((item) => item.latitude && item.longitude).length }{" "} items shown
)}
); }; export default SearchResultsMap;