From 08fe9b26d6b164abc63e5cfeae42b0b5cfc36b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A1rbara=20Chaves?= Date: Tue, 26 Nov 2024 20:51:19 +0100 Subject: [PATCH] Add location search --- client/src/app/[locale]/globals.css | 1 + client/src/components/map/types.ts | 2 + .../ui/openstreetmap-attribution.tsx | 11 ++ client/src/components/ui/search-location.tsx | 46 +++++ client/src/containers/map/controls/index.tsx | 2 + client/src/containers/map/index.tsx | 2 + .../containers/map/search-location/index.tsx | 176 ++++++++++++++++++ client/src/hooks/openstreetmaps.ts | 41 ++++ client/src/services/api/location-search.ts | 6 + client/tailwind.config.ts | 3 +- 10 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 client/src/components/ui/openstreetmap-attribution.tsx create mode 100644 client/src/components/ui/search-location.tsx create mode 100644 client/src/containers/map/search-location/index.tsx create mode 100644 client/src/hooks/openstreetmaps.ts create mode 100644 client/src/services/api/location-search.ts diff --git a/client/src/app/[locale]/globals.css b/client/src/app/[locale]/globals.css index 242998a..f46df48 100644 --- a/client/src/app/[locale]/globals.css +++ b/client/src/app/[locale]/globals.css @@ -6,6 +6,7 @@ --foreground-rgb: 33 31 77; --background-rgb: 250 255 246; --global-rgb: 235 135 49; + --popover-foreground-rgb: 2 6 23; --header-height: 78px; --content-height: calc(100vh - var(--header-height)); } diff --git a/client/src/components/map/types.ts b/client/src/components/map/types.ts index 1ded758..cd17a19 100644 --- a/client/src/components/map/types.ts +++ b/client/src/components/map/types.ts @@ -35,3 +35,5 @@ export type LegendComponent = { }; export type MapTooltipProps = Record; + +export type Bbox = [number, number, number, number]; diff --git a/client/src/components/ui/openstreetmap-attribution.tsx b/client/src/components/ui/openstreetmap-attribution.tsx new file mode 100644 index 0000000..60f2e6b --- /dev/null +++ b/client/src/components/ui/openstreetmap-attribution.tsx @@ -0,0 +1,11 @@ +const OpenStreetMapAttribution = () => ( + + @ OpenStreetMap + +); + +export default OpenStreetMapAttribution; diff --git a/client/src/components/ui/search-location.tsx b/client/src/components/ui/search-location.tsx new file mode 100644 index 0000000..da048d1 --- /dev/null +++ b/client/src/components/ui/search-location.tsx @@ -0,0 +1,46 @@ +import { PropsWithChildren } from "react"; + +type SearchResultListProps = PropsWithChildren & { + title: string; +}; +const SearchResultList = ({ title, children }: SearchResultListProps) => { + return ( +
+

{title}

+
    + {children} +
+
+ ); +}; + +type SearchOption = T & { + value: number | undefined; + label: string; +}; + +type SearchResultItemProps = PropsWithChildren & { + option: SearchOption; + onOptionClick: (option: SearchOption) => void; +}; +const SearchResultItem = ({ option, onOptionClick, children }: SearchResultItemProps) => { + return ( +
  • onOptionClick(option)} + onKeyDown={(e) => { + if (e.key === "Enter") { + onOptionClick(option); + } + }} + > + {children} + {option.label} +
  • + ); +}; + +export { SearchResultList, SearchResultItem }; diff --git a/client/src/containers/map/controls/index.tsx b/client/src/containers/map/controls/index.tsx index e188e9d..6962a61 100644 --- a/client/src/containers/map/controls/index.tsx +++ b/client/src/containers/map/controls/index.tsx @@ -1,9 +1,11 @@ import MapZoomControl from "@/components/map/controls/zoom"; import Legends from "../legends"; +import SearchLocation from "../search-location"; const MapControlsContainer = () => { return (
    +
    diff --git a/client/src/containers/map/index.tsx b/client/src/containers/map/index.tsx index ea59636..c14cf60 100644 --- a/client/src/containers/map/index.tsx +++ b/client/src/containers/map/index.tsx @@ -9,6 +9,7 @@ import Controls from "./controls"; import MapLayers from "@/containers/navigation/map-layers"; import { useSyncMapStyle } from "@/store/map"; import MapTooltip from "./popups"; +import OpenStreetMapAttribution from "@/components/ui/openstreetmap-attribution"; const Map = () => { const [mapStyle] = useSyncMapStyle(); @@ -34,6 +35,7 @@ const Map = () => { + ); }; diff --git a/client/src/containers/map/search-location/index.tsx b/client/src/containers/map/search-location/index.tsx new file mode 100644 index 0000000..185e7ef --- /dev/null +++ b/client/src/containers/map/search-location/index.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; +import { SearchResultItem, SearchResultList } from "@/components/ui/search-location"; +import { useOpenStreetMapsLocations } from "@/hooks/openstreetmaps"; +import { PopoverClose } from "@radix-ui/react-popover"; +import { ChevronRightIcon, MapIcon, MapPinIcon, SearchIcon, XIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import { LngLatBoundsLike, useMap } from "react-map-gl"; +import { useDebouncedValue } from "rooks"; + +type LocationOption = { + value: number | undefined; + label: string; + bbox: LngLatBoundsLike; +}; + +type StoryOption = Omit; + +const SearchLocation = () => { + const [open, setOpen] = useState(true); + const [locationSearch, setLocationSearch] = useState(""); + + const { current: map } = useMap(); + + const [debouncedSearch] = useDebouncedValue(locationSearch, 500); + + const { data: locationData = [] } = useOpenStreetMapsLocations( + { + q: debouncedSearch, + format: "json", + limit: 5, + }, + { + enabled: debouncedSearch.length >= 1, + placeholderData: (prev) => prev, + }, + ); + + const locationOptions = useMemo(() => { + if (!Array.isArray(locationData) || debouncedSearch.length < 1) return []; + return locationData?.reduce((prev, curr) => { + if (!curr.boundingbox) return prev; + // nominatim boundingbox: [min latitude, max latitude, min longitude, max longitude] + const [minLat, maxLat, minLng, maxLng] = curr.boundingbox; + + // mapbox bounds + // [[lng, lat] - southwestern corner of the bounds + // [lng, lat]] - northeastern corner of the bounds + const mapboxBounds = [ + [Number(minLng), Number(minLat)], + [Number(maxLng), Number(maxLat)], + ]; + + return [ + ...prev, + { + value: curr.place_id ?? undefined, + label: curr.display_name ?? "", + bbox: mapboxBounds as LngLatBoundsLike, + }, + ]; + }, []); + }, [locationData, debouncedSearch]); + + // TODO: add real stories + const storiesOptions: StoryOption[] = []; + + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + setLocationSearch(e.target.value); + }, []); + + const handleOptionClick = useCallback( + (option: LocationOption) => { + if (map) { + map.fitBounds(option.bbox, { + duration: 1000, + padding: { top: 50, bottom: 50, left: 350, right: 50 }, + }); + + setLocationSearch(""); + setOpen(false); + } + }, + [map], + ); + + const handleStoryOptionClick = useCallback((option: StoryOption) => { + // TODO: handle story option click + }, []); + + const handleOpenChange = useCallback((open: boolean) => { + setLocationSearch(""); + setOpen(open); + }, []); + + return ( +
    + + + + + +
    +
    + + + {locationSearch.length >= 1 && ( + + )} + + + +
    + + {!!locationOptions.length && ( + + {locationOptions.map((option) => ( + + + + ))} + + )} + + {!!storiesOptions?.length && ( + + {storiesOptions.map((option) => ( + + + + ))} + + )} +
    +
    +
    +
    + ); +}; + +export default SearchLocation; diff --git a/client/src/hooks/openstreetmaps.ts b/client/src/hooks/openstreetmaps.ts new file mode 100644 index 0000000..b5c6183 --- /dev/null +++ b/client/src/hooks/openstreetmaps.ts @@ -0,0 +1,41 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; + +import { Bbox } from "@/components/map/types"; +import { APIOpenStreetMapLocation } from "@/services/api/location-search"; + +export type Location = { + boundingbox: Bbox; + place_id: number; + display_name: string; + name: string; +}; + +const DEFAULT_QUERY_OPTIONS = { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + retry: false, + staleTime: Infinity, +}; +export function useOpenStreetMapsLocations( + params?: { + q: string; + format: string; + limit?: number; + }, + queryOptions?: Partial>, +) { + const fetchOpenStreetMapsLocation = () => + APIOpenStreetMapLocation.request({ + method: "GET", + url: "/search", + params, + }).then((response: AxiosResponse) => response.data); + return useQuery({ + queryKey: ["openstreetmaps", params], + queryFn: fetchOpenStreetMapsLocation, + ...DEFAULT_QUERY_OPTIONS, + ...queryOptions, + }); +} diff --git a/client/src/services/api/location-search.ts b/client/src/services/api/location-search.ts new file mode 100644 index 0000000..e727bc1 --- /dev/null +++ b/client/src/services/api/location-search.ts @@ -0,0 +1,6 @@ +import axios from "axios"; + +export const APIOpenStreetMapLocation = axios.create({ + baseURL: "https://nominatim.openstreetmap.org", + headers: {}, +}); diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 8578797..3ee2451 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -8,13 +8,14 @@ const config: Config = { global: "rgb(var(--global-rgb) / )", foreground: "rgb(var(--foreground-rgb) / )", background: "rgb(var(--background-rgb) / )", + "popover-foreground": "rgb(var(--popover-foreground-rgb) / )", }, lineHeight: { relaxed: "185%", }, }, container: { - padding: '2rem', + padding: "2rem", }, }, };