From 34524b3e0f781a912185c8d5930a6172bd25754c Mon Sep 17 00:00:00 2001 From: Tyler Yu Date: Tue, 8 Oct 2024 20:43:54 -0700 Subject: [PATCH] feat: geolocation search bar --- packages/web/package.json | 2 + packages/web/src/components/Map/Map.jsx | 210 ++++++++++++++++++++++-- pnpm-lock.yaml | 19 +++ 3 files changed, 220 insertions(+), 11 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index 7084359..6d2d2e3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,8 @@ "fuse.js": "^7.0.0", "ionicons": "^7.1.2", "leaflet": "^1.9.4", + "leaflet-geosearch": "^4.0.0", + "lodash": "^4.17.21", "react": "^18.2.0", "react-calendar": "^4.6.0", "react-dom": "^18.2.0", diff --git a/packages/web/src/components/Map/Map.jsx b/packages/web/src/components/Map/Map.jsx index 9bd0774..893d5c9 100644 --- a/packages/web/src/components/Map/Map.jsx +++ b/packages/web/src/components/Map/Map.jsx @@ -25,7 +25,7 @@ import { Circle, useMapEvents, } from "react-leaflet"; -import { useDisclosure, useColorMode } from "@chakra-ui/react"; +import { useDisclosure, useColorMode, useColorModeValue, useToast } from "@chakra-ui/react"; import InfoModal from "../InfoModal/InfoModal"; import DataContext from "../../context/DataContext"; @@ -35,6 +35,12 @@ import axios from "axios"; import { filterItem } from "../../utils/Utils.js"; +// Add this new import for the geocoding service +import { OpenStreetMapProvider } from 'leaflet-geosearch'; +import { Input, Button, Box, IconButton, Spinner, VStack, Text } from "@chakra-ui/react"; // Import Chakra UI components +import { SearchIcon } from "@chakra-ui/icons"; +import debounce from 'lodash/debounce'; + /** * Map is uses react-leaflet's API to communicate user actions to map entities and information * @@ -94,11 +100,11 @@ export default function Map({ ]; const bounds = L.latLngBounds(allowedBounds); - const mapBoundsCoordinates = [ - [33.625038, -117.875143], - [33.668298, -117.808742], - ]; - const mapBounds = L.latLngBounds(mapBoundsCoordinates); + // const mapBoundsCoordinates = [ + // [33.625038, -117.875143], + // [33.668298, -117.808742], + // ]; + // const mapBounds = L.latLngBounds(mapBoundsCoordinates); const handleMarkerSelect = async () => { setShowDonut(true); @@ -333,18 +339,32 @@ export default function Map({ ) : null; }; + const [locationSearch, setLocationSearch] = useState(""); + const provider = useMemo(() => new OpenStreetMapProvider(), []); + + const handleLocationSearch = useCallback(async () => { + if (locationSearch.trim() === "") return; + + try { + const results = await provider.search({ query: locationSearch }); + if (results.length > 0) { + const { x, y } = results[0]; + setFocusLocation([y, x]); + } + } catch (error) { + console.error("Error searching for location:", error); + } + }, [locationSearch, provider, setFocusLocation]); + return (
- {/* Styles applied to MapContainer don't render unless page is reloaded */} )} - + + {isOpen && ( ); } + +function MapControls({ locationSearch, setLocationSearch, handleLocationSearch, focusLocation, setFocusLocation }) { + const map = useMap(); + const bg = useColorModeValue("white", "gray.800"); + const color = useColorModeValue("gray.800", "white"); + const placeholderColor = useColorModeValue("gray.500", "gray.400"); + const [isLoading, setIsLoading] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const suggestionsRef = useRef(null); + + useEffect(() => { + if (focusLocation) { + map.flyTo(focusLocation, 18); + } + }, [focusLocation, map]); + + useEffect(() => { + const handleClickOutside = (event) => { + if (suggestionsRef.current && !suggestionsRef.current.contains(event.target)) { + setShowSuggestions(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + useMapEvents({ + click: () => { + setShowSuggestions(false); + }, + }); + + const fetchSuggestions = async (value) => { + if (value.length > 2) { + try { + const response = await axios.get( + `https://nominatim.openstreetmap.org/search?format=json&q=${value}&limit=5` + ); + setSuggestions(response.data); + setShowSuggestions(true); + } catch (error) { + console.error("Error fetching suggestions:", error); + } + } else { + setSuggestions([]); + setShowSuggestions(false); + } + }; + + // Debounce the fetchSuggestions function + const debouncedFetchSuggestions = useCallback( + debounce(fetchSuggestions, 300), + [] + ); + + const handleInputChange = (e) => { + const value = e.target.value; + setLocationSearch(value); + debouncedFetchSuggestions(value); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + performSearch(); + } + }; + + const performSearch = async () => { + setIsLoading(true); + setShowSuggestions(false); + try { + await handleLocationSearch(); + } catch (error) { + console.error("Error searching for location:", error); + } finally { + setIsLoading(false); + } + }; + + const handleSuggestionClick = (suggestion) => { + setLocationSearch(suggestion.display_name); + setShowSuggestions(false); + setFocusLocation([parseFloat(suggestion.lat), parseFloat(suggestion.lon)]); + }; + + return ( + + {showSuggestions && suggestions.length > 0 && ( + + {suggestions.map((suggestion) => ( + handleSuggestionClick(suggestion)} + > + {suggestion.display_name} + + ))} + + )} + + + {isLoading ? ( + + ) : ( + } + onClick={performSearch} + size="sm" + colorScheme="blue" + variant="ghost" + borderRadius="full" + aria-label="Search location" + minWidth="40px" + /> + )} + + + ); +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6148c5a..755b5cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,12 @@ importers: leaflet: specifier: ^1.9.4 version: 1.9.4 + leaflet-geosearch: + specifier: ^4.0.0 + version: 4.0.0 + lodash: + specifier: ^4.17.21 + version: 4.17.21 react: specifier: ^18.2.0 version: 18.2.0 @@ -5029,6 +5035,12 @@ packages: dev: false optional: true + /@googlemaps/js-api-loader@1.16.8: + resolution: {integrity: sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==} + requiresBuild: true + dev: false + optional: true + /@graphql-tools/executor@0.0.18(graphql@16.8.1): resolution: {integrity: sha512-xZC0C+/npXoSHBB5bsJdwxDLgtl1Gu4fL9J2TPQmXoZC3L2N506KJoppf9LgWdHU/xK04luJrhP6WjhfkIN0pQ==} peerDependencies: @@ -9441,6 +9453,13 @@ packages: readable-stream: 2.3.8 dev: true + /leaflet-geosearch@4.0.0: + resolution: {integrity: sha512-a92VNY9gxyv3oyEDqIWoCNoBllajWRYejztzOSNmpLRtzpA6JtGgy/wwl9tsB8+6Eek1fe+L6+W0MDEOaidbXA==} + optionalDependencies: + '@googlemaps/js-api-loader': 1.16.8 + leaflet: 1.9.4 + dev: false + /leaflet@1.9.4: resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} dev: false