Skip to content

Commit d230ec2

Browse files
Merge pull request #85 from Vizzuality/develop
Add location search
2 parents b5c24d2 + 08fe9b2 commit d230ec2

File tree

10 files changed

+289
-1
lines changed

10 files changed

+289
-1
lines changed

client/src/app/[locale]/globals.css

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
--foreground-rgb: 33 31 77;
77
--background-rgb: 250 255 246;
88
--global-rgb: 235 135 49;
9+
--popover-foreground-rgb: 2 6 23;
910
--header-height: 78px;
1011
--content-height: calc(100vh - var(--header-height));
1112
}

client/src/components/map/types.ts

+2
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ export type LegendComponent = {
3535
};
3636

3737
export type MapTooltipProps = Record<string, unknown>;
38+
39+
export type Bbox = [number, number, number, number];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const OpenStreetMapAttribution = () => (
2+
<a
3+
href="https://openstreetmap.org/copyright"
4+
target="_blank"
5+
className="fixed bottom-0.5 right-0 z-[100] mr-[90px] h-4 text-[10px] text-gray-500 opacity-0"
6+
>
7+
@ OpenStreetMap
8+
</a>
9+
);
10+
11+
export default OpenStreetMapAttribution;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { PropsWithChildren } from "react";
2+
3+
type SearchResultListProps = PropsWithChildren & {
4+
title: string;
5+
};
6+
const SearchResultList = ({ title, children }: SearchResultListProps) => {
7+
return (
8+
<div className="border-t border-slate-200 px-1 py-1.5">
9+
<p className="px-2 py-1.5 text-xs text-slate-500">{title}</p>
10+
<ul id="location-options" role="listbox" className="">
11+
{children}
12+
</ul>
13+
</div>
14+
);
15+
};
16+
17+
type SearchOption<T> = T & {
18+
value: number | undefined;
19+
label: string;
20+
};
21+
22+
type SearchResultItemProps<T> = PropsWithChildren & {
23+
option: SearchOption<T>;
24+
onOptionClick: (option: SearchOption<T>) => void;
25+
};
26+
const SearchResultItem = <T,>({ option, onOptionClick, children }: SearchResultItemProps<T>) => {
27+
return (
28+
<li
29+
role="option"
30+
aria-selected="false"
31+
tabIndex={0}
32+
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"
33+
onClick={() => onOptionClick(option)}
34+
onKeyDown={(e) => {
35+
if (e.key === "Enter") {
36+
onOptionClick(option);
37+
}
38+
}}
39+
>
40+
{children}
41+
<span className="line-clamp-2">{option.label}</span>
42+
</li>
43+
);
44+
};
45+
46+
export { SearchResultList, SearchResultItem };

client/src/containers/map/controls/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import MapZoomControl from "@/components/map/controls/zoom";
22
import Legends from "../legends";
3+
import SearchLocation from "../search-location";
34

45
const MapControlsContainer = () => {
56
return (
67
<div className="absolute bottom-6 right-5 space-y-1.5">
8+
<SearchLocation />
79
<MapZoomControl />
810
<Legends />
911
</div>

client/src/containers/map/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Controls from "./controls";
99
import MapLayers from "@/containers/navigation/map-layers";
1010
import { useSyncMapStyle } from "@/store/map";
1111
import MapTooltip from "./popups";
12+
import OpenStreetMapAttribution from "@/components/ui/openstreetmap-attribution";
1213

1314
const Map = () => {
1415
const [mapStyle] = useSyncMapStyle();
@@ -34,6 +35,7 @@ const Map = () => {
3435
<MapStyles />
3536
<MapLayers />
3637
</Navigation>
38+
<OpenStreetMapAttribution />
3739
</div>
3840
);
3941
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
5+
import { SearchResultItem, SearchResultList } from "@/components/ui/search-location";
6+
import { useOpenStreetMapsLocations } from "@/hooks/openstreetmaps";
7+
import { PopoverClose } from "@radix-ui/react-popover";
8+
import { ChevronRightIcon, MapIcon, MapPinIcon, SearchIcon, XIcon } from "lucide-react";
9+
import { useCallback, useMemo, useState } from "react";
10+
import { LngLatBoundsLike, useMap } from "react-map-gl";
11+
import { useDebouncedValue } from "rooks";
12+
13+
type LocationOption = {
14+
value: number | undefined;
15+
label: string;
16+
bbox: LngLatBoundsLike;
17+
};
18+
19+
type StoryOption = Omit<LocationOption, "bbox">;
20+
21+
const SearchLocation = () => {
22+
const [open, setOpen] = useState(true);
23+
const [locationSearch, setLocationSearch] = useState("");
24+
25+
const { current: map } = useMap();
26+
27+
const [debouncedSearch] = useDebouncedValue(locationSearch, 500);
28+
29+
const { data: locationData = [] } = useOpenStreetMapsLocations(
30+
{
31+
q: debouncedSearch,
32+
format: "json",
33+
limit: 5,
34+
},
35+
{
36+
enabled: debouncedSearch.length >= 1,
37+
placeholderData: (prev) => prev,
38+
},
39+
);
40+
41+
const locationOptions = useMemo(() => {
42+
if (!Array.isArray(locationData) || debouncedSearch.length < 1) return [];
43+
return locationData?.reduce<LocationOption[]>((prev, curr) => {
44+
if (!curr.boundingbox) return prev;
45+
// nominatim boundingbox: [min latitude, max latitude, min longitude, max longitude]
46+
const [minLat, maxLat, minLng, maxLng] = curr.boundingbox;
47+
48+
// mapbox bounds
49+
// [[lng, lat] - southwestern corner of the bounds
50+
// [lng, lat]] - northeastern corner of the bounds
51+
const mapboxBounds = [
52+
[Number(minLng), Number(minLat)],
53+
[Number(maxLng), Number(maxLat)],
54+
];
55+
56+
return [
57+
...prev,
58+
{
59+
value: curr.place_id ?? undefined,
60+
label: curr.display_name ?? "",
61+
bbox: mapboxBounds as LngLatBoundsLike,
62+
},
63+
];
64+
}, []);
65+
}, [locationData, debouncedSearch]);
66+
67+
// TODO: add real stories
68+
const storiesOptions: StoryOption[] = [];
69+
70+
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
71+
setLocationSearch(e.target.value);
72+
}, []);
73+
74+
const handleOptionClick = useCallback(
75+
(option: LocationOption) => {
76+
if (map) {
77+
map.fitBounds(option.bbox, {
78+
duration: 1000,
79+
padding: { top: 50, bottom: 50, left: 350, right: 50 },
80+
});
81+
82+
setLocationSearch("");
83+
setOpen(false);
84+
}
85+
},
86+
[map],
87+
);
88+
89+
const handleStoryOptionClick = useCallback((option: StoryOption) => {
90+
// TODO: handle story option click
91+
}, []);
92+
93+
const handleOpenChange = useCallback((open: boolean) => {
94+
setLocationSearch("");
95+
setOpen(open);
96+
}, []);
97+
98+
return (
99+
<div>
100+
<Popover open={open} onOpenChange={handleOpenChange}>
101+
<PopoverTrigger asChild>
102+
<Button
103+
onClick={() => setOpen(!open)}
104+
variant="ghost"
105+
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"
106+
>
107+
<SearchIcon className="h-5 w-5 stroke-foreground stroke-[1.5px]" />
108+
</Button>
109+
</PopoverTrigger>
110+
<PopoverContent
111+
align="start"
112+
alignOffset={20}
113+
sideOffset={20}
114+
side="left"
115+
className="relative z-50 w-[348px] -translate-y-10 overflow-hidden rounded-lg bg-background px-0 py-0 shadow-lg drop-shadow-2xl"
116+
>
117+
<div>
118+
<div className="relative flex items-center justify-between p-1">
119+
<SearchIcon className="absolute left-3 h-5 w-5 stroke-slate-300 stroke-[1.5px]" />
120+
<input
121+
onChange={handleSearchChange}
122+
type="text"
123+
value={locationSearch}
124+
placeholder="Search"
125+
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"
126+
/>
127+
{locationSearch.length >= 1 && (
128+
<Button
129+
variant="ghost"
130+
size="sm"
131+
onClick={() => setLocationSearch("")}
132+
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"
133+
>
134+
<XIcon className="h-4 w-4 stroke-slate-400 stroke-[1.5px]" />
135+
</Button>
136+
)}
137+
<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">
138+
<ChevronRightIcon className="mx-auto h-5 w-5" />
139+
</PopoverClose>
140+
</div>
141+
142+
{!!locationOptions.length && (
143+
<SearchResultList title="Locations">
144+
{locationOptions.map((option) => (
145+
<SearchResultItem
146+
key={option.value}
147+
option={option}
148+
onOptionClick={handleOptionClick}
149+
>
150+
<MapPinIcon className="mt-0.5 h-4 w-4 shrink-0" />
151+
</SearchResultItem>
152+
))}
153+
</SearchResultList>
154+
)}
155+
156+
{!!storiesOptions?.length && (
157+
<SearchResultList title="Rangelands stories">
158+
{storiesOptions.map((option) => (
159+
<SearchResultItem
160+
key={option.value}
161+
option={option}
162+
onOptionClick={handleStoryOptionClick}
163+
>
164+
<MapIcon className="mt-0.5 h-4 w-4 shrink-0" />
165+
</SearchResultItem>
166+
))}
167+
</SearchResultList>
168+
)}
169+
</div>
170+
</PopoverContent>
171+
</Popover>
172+
</div>
173+
);
174+
};
175+
176+
export default SearchLocation;

client/src/hooks/openstreetmaps.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
2+
import { AxiosResponse } from "axios";
3+
4+
import { Bbox } from "@/components/map/types";
5+
import { APIOpenStreetMapLocation } from "@/services/api/location-search";
6+
7+
export type Location = {
8+
boundingbox: Bbox;
9+
place_id: number;
10+
display_name: string;
11+
name: string;
12+
};
13+
14+
const DEFAULT_QUERY_OPTIONS = {
15+
refetchOnWindowFocus: false,
16+
refetchOnMount: false,
17+
refetchOnReconnect: false,
18+
retry: false,
19+
staleTime: Infinity,
20+
};
21+
export function useOpenStreetMapsLocations(
22+
params?: {
23+
q: string;
24+
format: string;
25+
limit?: number;
26+
},
27+
queryOptions?: Partial<UseQueryOptions<Location[], Error>>,
28+
) {
29+
const fetchOpenStreetMapsLocation = () =>
30+
APIOpenStreetMapLocation.request({
31+
method: "GET",
32+
url: "/search",
33+
params,
34+
}).then((response: AxiosResponse<Location[]>) => response.data);
35+
return useQuery({
36+
queryKey: ["openstreetmaps", params],
37+
queryFn: fetchOpenStreetMapsLocation,
38+
...DEFAULT_QUERY_OPTIONS,
39+
...queryOptions,
40+
});
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import axios from "axios";
2+
3+
export const APIOpenStreetMapLocation = axios.create({
4+
baseURL: "https://nominatim.openstreetmap.org",
5+
headers: {},
6+
});

client/tailwind.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ const config: Config = {
88
global: "rgb(var(--global-rgb) / <alpha-value>)",
99
foreground: "rgb(var(--foreground-rgb) / <alpha-value>)",
1010
background: "rgb(var(--background-rgb) / <alpha-value>)",
11+
"popover-foreground": "rgb(var(--popover-foreground-rgb) / <alpha-value>)",
1112
},
1213
lineHeight: {
1314
relaxed: "185%",
1415
},
1516
},
1617
container: {
17-
padding: '2rem',
18+
padding: "2rem",
1819
},
1920
},
2021
};

0 commit comments

Comments
 (0)