Skip to content

Commit

Permalink
Merge pull request #85 from Vizzuality/develop
Browse files Browse the repository at this point in the history
Add location search
  • Loading branch information
barbara-chaves authored Nov 27, 2024
2 parents b5c24d2 + 08fe9b2 commit d230ec2
Show file tree
Hide file tree
Showing 10 changed files with 289 additions and 1 deletion.
1 change: 1 addition & 0 deletions client/src/app/[locale]/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ export type LegendComponent = {
};

export type MapTooltipProps = Record<string, unknown>;

export type Bbox = [number, number, number, number];
11 changes: 11 additions & 0 deletions client/src/components/ui/openstreetmap-attribution.tsx
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;
46 changes: 46 additions & 0 deletions client/src/components/ui/search-location.tsx
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 };
2 changes: 2 additions & 0 deletions client/src/containers/map/controls/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import MapZoomControl from "@/components/map/controls/zoom";
import Legends from "../legends";
import SearchLocation from "../search-location";

const MapControlsContainer = () => {
return (
<div className="absolute bottom-6 right-5 space-y-1.5">
<SearchLocation />
<MapZoomControl />
<Legends />
</div>
Expand Down
2 changes: 2 additions & 0 deletions client/src/containers/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -34,6 +35,7 @@ const Map = () => {
<MapStyles />
<MapLayers />
</Navigation>
<OpenStreetMapAttribution />
</div>
);
};
Expand Down
176 changes: 176 additions & 0 deletions client/src/containers/map/search-location/index.tsx
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;
41 changes: 41 additions & 0 deletions client/src/hooks/openstreetmaps.ts
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,
});
}
6 changes: 6 additions & 0 deletions client/src/services/api/location-search.ts
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: {},
});
3 changes: 2 additions & 1 deletion client/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ const config: Config = {
global: "rgb(var(--global-rgb) / <alpha-value>)",
foreground: "rgb(var(--foreground-rgb) / <alpha-value>)",
background: "rgb(var(--background-rgb) / <alpha-value>)",
"popover-foreground": "rgb(var(--popover-foreground-rgb) / <alpha-value>)",
},
lineHeight: {
relaxed: "185%",
},
},
container: {
padding: '2rem',
padding: "2rem",
},
},
};
Expand Down

0 comments on commit d230ec2

Please sign in to comment.