-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #85 from Vizzuality/develop
Add location search
- Loading branch information
Showing
10 changed files
with
289 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
const OpenStreetMapAttribution = () => ( | ||
<a | ||
href="https://openstreetmap.org/copyright" | ||
target="_blank" | ||
className="fixed bottom-0.5 right-0 z-[100] mr-[90px] h-4 text-[10px] text-gray-500 opacity-0" | ||
> | ||
@ OpenStreetMap | ||
</a> | ||
); | ||
|
||
export default OpenStreetMapAttribution; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import { PropsWithChildren } from "react"; | ||
|
||
type SearchResultListProps = PropsWithChildren & { | ||
title: string; | ||
}; | ||
const SearchResultList = ({ title, children }: SearchResultListProps) => { | ||
return ( | ||
<div className="border-t border-slate-200 px-1 py-1.5"> | ||
<p className="px-2 py-1.5 text-xs text-slate-500">{title}</p> | ||
<ul id="location-options" role="listbox" className=""> | ||
{children} | ||
</ul> | ||
</div> | ||
); | ||
}; | ||
|
||
type SearchOption<T> = T & { | ||
value: number | undefined; | ||
label: string; | ||
}; | ||
|
||
type SearchResultItemProps<T> = PropsWithChildren & { | ||
option: SearchOption<T>; | ||
onOptionClick: (option: SearchOption<T>) => void; | ||
}; | ||
const SearchResultItem = <T,>({ option, onOptionClick, children }: SearchResultItemProps<T>) => { | ||
return ( | ||
<li | ||
role="option" | ||
aria-selected="false" | ||
tabIndex={0} | ||
className="hover:text-secondary-500 flex cursor-pointer gap-2 rounded px-2 py-2 text-sm transition-all duration-300 hover:bg-orange-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-global" | ||
onClick={() => onOptionClick(option)} | ||
onKeyDown={(e) => { | ||
if (e.key === "Enter") { | ||
onOptionClick(option); | ||
} | ||
}} | ||
> | ||
{children} | ||
<span className="line-clamp-2">{option.label}</span> | ||
</li> | ||
); | ||
}; | ||
|
||
export { SearchResultList, SearchResultItem }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<LocationOption, "bbox">; | ||
|
||
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<LocationOption[]>((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<HTMLInputElement>) => { | ||
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 ( | ||
<div> | ||
<Popover open={open} onOpenChange={handleOpenChange}> | ||
<PopoverTrigger asChild> | ||
<Button | ||
onClick={() => setOpen(!open)} | ||
variant="ghost" | ||
className="transition-color block h-min rounded-full border-2 border-background bg-background px-2 py-2 shadow-black/10 drop-shadow-md duration-300 hover:bg-orange-100 focus-visible:bg-global data-[state=open]:bg-global" | ||
> | ||
<SearchIcon className="h-5 w-5 stroke-foreground stroke-[1.5px]" /> | ||
</Button> | ||
</PopoverTrigger> | ||
<PopoverContent | ||
align="start" | ||
alignOffset={20} | ||
sideOffset={20} | ||
side="left" | ||
className="relative z-50 w-[348px] -translate-y-10 overflow-hidden rounded-lg bg-background px-0 py-0 shadow-lg drop-shadow-2xl" | ||
> | ||
<div> | ||
<div className="relative flex items-center justify-between p-1"> | ||
<SearchIcon className="absolute left-3 h-5 w-5 stroke-slate-300 stroke-[1.5px]" /> | ||
<input | ||
onChange={handleSearchChange} | ||
type="text" | ||
value={locationSearch} | ||
placeholder="Search" | ||
className="placeholder:text-popover-foreground/50 w-full border-2 border-background bg-background p-2 px-9 text-sm leading-none text-foreground placeholder:text-sm placeholder:font-light focus-visible:outline-global" | ||
/> | ||
{locationSearch.length >= 1 && ( | ||
<Button | ||
variant="ghost" | ||
size="sm" | ||
onClick={() => setLocationSearch("")} | ||
className="absolute right-9 h-fit w-fit rounded-full p-0.5 hover:bg-orange-100 focus-visible:ring-global data-[state=open]:bg-global" | ||
> | ||
<XIcon className="h-4 w-4 stroke-slate-400 stroke-[1.5px]" /> | ||
</Button> | ||
)} | ||
<PopoverClose className="mr-1 h-fit w-fit rounded-full p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-global"> | ||
<ChevronRightIcon className="mx-auto h-5 w-5" /> | ||
</PopoverClose> | ||
</div> | ||
|
||
{!!locationOptions.length && ( | ||
<SearchResultList title="Locations"> | ||
{locationOptions.map((option) => ( | ||
<SearchResultItem | ||
key={option.value} | ||
option={option} | ||
onOptionClick={handleOptionClick} | ||
> | ||
<MapPinIcon className="mt-0.5 h-4 w-4 shrink-0" /> | ||
</SearchResultItem> | ||
))} | ||
</SearchResultList> | ||
)} | ||
|
||
{!!storiesOptions?.length && ( | ||
<SearchResultList title="Rangelands stories"> | ||
{storiesOptions.map((option) => ( | ||
<SearchResultItem | ||
key={option.value} | ||
option={option} | ||
onOptionClick={handleStoryOptionClick} | ||
> | ||
<MapIcon className="mt-0.5 h-4 w-4 shrink-0" /> | ||
</SearchResultItem> | ||
))} | ||
</SearchResultList> | ||
)} | ||
</div> | ||
</PopoverContent> | ||
</Popover> | ||
</div> | ||
); | ||
}; | ||
|
||
export default SearchLocation; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<UseQueryOptions<Location[], Error>>, | ||
) { | ||
const fetchOpenStreetMapsLocation = () => | ||
APIOpenStreetMapLocation.request({ | ||
method: "GET", | ||
url: "/search", | ||
params, | ||
}).then((response: AxiosResponse<Location[]>) => response.data); | ||
return useQuery({ | ||
queryKey: ["openstreetmaps", params], | ||
queryFn: fetchOpenStreetMapsLocation, | ||
...DEFAULT_QUERY_OPTIONS, | ||
...queryOptions, | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import axios from "axios"; | ||
|
||
export const APIOpenStreetMapLocation = axios.create({ | ||
baseURL: "https://nominatim.openstreetmap.org", | ||
headers: {}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters