map search

This commit is contained in:
jackiettran
2025-09-10 16:41:05 -04:00
parent 1d7db138df
commit 688f5ac8d6
4 changed files with 602 additions and 14 deletions

View File

@@ -0,0 +1,432 @@
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<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
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<SearchResultsMapProps> = ({
items,
searchLocation,
className,
style,
onItemSelect,
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
const markersRef = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
const mapInitializedRef = useRef<boolean>(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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(
<ItemMarkerInfo
item={item}
onViewDetails={() => {
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 (
<div className={className} style={{ ...defaultStyle, ...style }}>
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center">
<i
className="bi bi-geo-alt text-muted mb-3"
style={{ fontSize: "3rem" }}
></i>
<h5 className="text-muted">No Location Data</h5>
<p className="text-muted small mb-0">
None of the search results have location information available for
mapping.
</p>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className={className} style={{ ...defaultStyle, ...style }}>
<div className="d-flex align-items-center justify-content-center h-100">
<div className="text-center">
<i
className="bi bi-exclamation-triangle text-warning mb-2"
style={{ fontSize: "3rem" }}
></i>
<p className="text-muted">{error}</p>
<button
className="btn btn-outline-primary btn-sm"
onClick={() => window.location.reload()}
>
Retry
</button>
</div>
</div>
</div>
);
}
return (
<div
className={className}
style={{ ...defaultStyle, ...style, position: "relative" }}
>
{isLoading && (
<div
className="position-absolute d-flex align-items-center justify-content-center w-100 h-100"
style={{
backgroundColor: "rgba(248, 249, 250, 0.8)",
borderRadius: "8px",
zIndex: 1000,
}}
>
<div className="text-center">
<div className="spinner-border text-primary mb-2" role="status">
<span className="visually-hidden">Loading map...</span>
</div>
<p className="text-muted small">Loading map...</p>
</div>
</div>
)}
<div
ref={mapRef}
style={{ width: "100%", height: "100%", borderRadius: "8px" }}
/>
{!isLoading && items.length > 0 && (
<div
className="position-absolute bottom-0 start-0 bg-white px-3 py-2 rounded-top-2 shadow-sm"
style={{ margin: "0 0 10px 10px", zIndex: 500 }}
>
<small className="text-muted">
<i className="bi bi-geo-alt-fill text-primary me-1"></i>
{
items.filter((item) => item.latitude && item.longitude).length
}{" "}
items shown
</small>
</div>
)}
</div>
);
};
export default SearchResultsMap;