Criando Soluções Geográficas com Mapas no Frontend
Fala pessoas!!! Me apresentando, sou Kevin Uehara, Senior Frontend Engineer no IFood, atuando na tribo logística, no time de Location. Atuo de dois times dentro de Location, sendo Location Geo e Location Areas.
Nossos times lidam com toda parte de geo-processamento, geo-localização, áreas de entrega de parceiros, rotas e muito mais!
Neste artigo, apresentarei um aplicativo de demonstração usando mapa, criando a melhor rota entre duas localizações, e será possível escolher três tipos de modo de viagem, sendo que ao escolher o tipo, irá influençar na rota apresentada. INCRÍVEL, NÉ? Magia ou tecnologia? Vamos ver…
Introdução
Como mencionei anteriormente (spoilers) vou usar o React com Typescript como biblioteca frontend, além de usar o Vite como ferramenta para gerenciar pacotes/dependências e criar o projeto. Por si só, o Vite (use rollup) já valeria outro artigo falando apenas sobre ele, mas para não fugir do objetivo deste artigo, ao final deixarei links para cada documentação. Então, ao invés de usar o Create React App (CRA), estarei usando o Vite, que nos fornecerá tudo o que precisamos, velocidade e sua arquitetura enxuta.
Para facilitar nossa vida, também estarei utilizando o Tailwind para estilizar nossa aplicação, trazendo nossos estilos de forma simples e fácil de aplicar.
Também usarei a biblioteca de código aberto MapLibre para renderização de mapas. O React Map GL que nos fornecerá vários componentes React focados em interações no mapa.
Além disso, usarei o MapTiler como um estilo de mapa. O MapTiler nos fornecerá um mapa mais bonito e limpo, sendo gratuito até um limite de solicitações, responsável por trazer alguns tipos de estilizações, sendo customizável e trazedo vários outros tipos de visualizações. Por se tratar de um aplicativo de demonstração e exemplo, não vamos nos preocupar com isso, mas fique atento a esse ponto (lembrando que existem estilos de mapa de código aberto do Open Street Maps, comumente conhecido como OSM, que você pode utilizar).
Para a Geocodificação (Geocoder), sugerindo endereços conforme o usuário digita e transformando a localização em um ponto (latitude e longitude), estarei utilizando o Nominatim. É uma ferramenta de código aberto e gratuita.
E por fim, para o cálculo e sugestão de rota, estarei utilizando a própria API do Google Maps. Vale ressaltar também que existe um limite de requisições para uso gratuito e por se tratar de uma demonstração não nos preocuparemos com isso. Mas, a título de curiosidade, existe outra ferramenta de código aberto chamada OSRM (Open Source Route Machine), que também calcula e sugere rotas, com base em mapas OSM (Open Street Maps) criados em C++.
Em resumo vamos utilizar:
- Vite (Frontend Tooling)
- React + typescript
- Tailwind
- Google Maps API (para criação da rota)
- MapLibre (biblioteca para renderizar o mapa)
- MapTiler (Provedor de estilização de mapa)
- React Map GL (Componentes React para se utilizar no mapa)
- Nominatim (API Open Source para Geocoding)
Arquitetura
Para esta aplicação não usei nenhum gerenciador de estado, por exemplo Context API, Jotai, Redux ou Recoil. Apenas usando prop-drilling, porque a hierarquia de componentes é pequena e simples, não é necessário usar um gerenciador de estado global
Antes de mostrar o código, vamos ver um pouco da arquitetura da aplicação que iremos construir:
E os componentes que iremos criar:
Falei demais… Então show me the code!
Vamos criar o projeto vite utilizando o comando:
yarn create vite map-router --template react-ts
E vamos instalar as dependências (vou utilizar o yarn)
cd map-router
yarn
Agora, vamos inicializar a aplicação:
yarn dev
E vamos ter gerado uma aplicação com essa estrutura:
Simples, não?
Agora, vamos configurar o Tailwind (nada de novo, iremos apenas seguir a documentação)
Como mencionei, a API do Google Maps e o MapTiler necessitam de um registro e API Keys (como comentei, este é um aplicativo demo, então nada será cobrado após muitas requisições). Então, vou criar um .env contendo as duas chaves de API. vamos construir:
VITE_MAPTILER_API_KEY={your api key of maptiler}
VITE_GOOGLE_API_KEY={your api key of google cloud project}
E agora vamos instalar as dependências que vamos utilizar:
yarn add axios google-maps mapbox-gl maplibre-gl react-map-gl @mapbox/polyline
E vamos precisar apenas adicionar apenas uma dependência de desenvolvimento para o types do mapbox/polyline (será utilizar para criar a rota no mapa)
yarn add -D @types/mapbox__polyline
Vamos alterar o arquivo main.tsx para adicionar o Provider do React Map GL:
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./index.css";
import { MapProvider } from "react-map-gl";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<MapProvider>
<App />
</MapProvider>
</React.StrictMode>
);
Agora vamos remover o conteúdo do App.tsx e substituir por esse código:
import Map from "react-map-gl";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { useMemo } from "react";
const MAPTILER_API_KEY = import.meta.env.VITE_MAPTILER_API_KEY;
const MAPS_DEFAULT_LOCATION = {
latitude: -22.9064,
longitude: -47.0616,
zoom: 6,
};
export const App = () => {
const mapTilerMapStyle = useMemo(() => {
return `https://api.maptiler.com/maps/basic-v2/style.json?key=${MAPTILER_API_KEY}`;
}, []);
return (
<>
<Map
initialViewState={{
...MAPS_DEFAULT_LOCATION,
}}
style={{
width: "100wh",
height: "100vh",
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
}}
hash
mapLib={maplibregl}
mapStyle={mapTilerMapStyle}
></Map>
</>
);
};
Gerando como resultado:
UHUU Incrível! Nós temos uma MAPA na nossa aplicação!
Agora vamos criar alguns diretórios para nossos componentes e o service:
Primeiro vamos criar nosso serviço que irá integrar com o Nominatim para buscar algum localização, e requisitar como resposta um ponto (latituge/longitude). Este serviço será chamado como index.tsx em services/GeocoderServices.
import { GeocoderResult } from "../components/types";
import axios from "axios";
export class GeocoderService {
private static NOMINATIM_HOST = "https://nominatim.openstreetmap.org/search?";
static getResults = async (searchText: string) => {
const params = {
q: searchText,
format: "json",
addressdetails: "1",
polygon_geojson: "0",
};
const queryString = new URLSearchParams(params).toString();
const requestOption = {
method: "GET",
};
const response = await axios.get(
`${GeocoderService.NOMINATIM_HOST}${queryString}`,
requestOption
);
const resultParsed: GeocoderResult[] = response.data.map(
(item: GeocoderResult) => ({
display_name: item.display_name,
lat: item.lat,
lon: item.lon,
place_id: item.place_id,
})
);
return resultParsed;
};
O tipo GeocoderResult, visto no código vamos criar em components/types.ts.
export interface GeocoderResult {
place_id: string;
display_name: string;
lat: string;
lon: string;
}
export type SearchValueType = GeocoderResult | string | undefined;
Nesta aplicação iremos utilizar alguns ícones, então resolvi criar ícones JSX, como pin e search icons em components/icons/index.tsx:
export const pinIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</svg>
);
export const searchIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
export const pinIconMap = (size = 20) => {
const pinStyle = {
cursor: "pointer",
fill: "#d00",
stroke: "none",
};
return (
<svg height={size} viewBox="0 0 24 24" style={pinStyle}>
<path
d="M20.2,15.7L20.2,15.7c1.1-1.6,1.8-3.6,1.8-5.7c0-5.6-4.5-10-10-10S2,4.5,2,10c0,2,0.6,3.9,1.6,5.4c0,0.1,0.1,0.2,0.2,0.3
c0,0,0.1,0.1,0.1,0.2c0.2,0.3,0.4,0.6,0.7,0.9c2.6,3.1,7.4,7.6,7.4,7.6s4.8-4.5,7.4-7.5c0.2-0.3,0.5-0.6,0.7-0.9
C20.1,15.8,20.2,15.8,20.2,15.7z`"
/>
</svg>
);
};
Nosso primeiro componente será o GeocoderInput. Ele receberá um placeholder, resultados do Nominatim, valor selecionado, callback para o onSelect e callback para o onSearch.
import { GeocoderResult, SearchValueType } from "../types";
import { pinIcon, searchIcon } from "../icons";
import { useState } from "react";
interface GeocoderInputProps {
placeholder?: string;
results: GeocoderResult[];
valueSelectedOnAutoComplete?: GeocoderResult | string;
onSelect: (value: GeocoderResult) => void;
onSearch: (event: any) => void;
}
export const GeocoderInput = ({
placeholder,
onSearch,
results,
onSelect,
valueSelectedOnAutoComplete,
}: GeocoderInputProps) => {
const [searchValue, setSearchValue] = useState("");
const getValueToDisplayOnInput = (
valueSelectedOnAutoComplete: SearchValueType
) => {
const valueSelectedType: GeocoderResult =
valueSelectedOnAutoComplete as GeocoderResult;
if (!valueSelectedOnAutoComplete && !searchValue) {
return "";
}
return valueSelectedType ? valueSelectedType.display_name : searchValue;
};
return (
<div className="z-10 relative w-full">
<div className="flex">
<input
type="search"
name="geocoder"
onChange={(e) => setSearchValue(e.target.value)}
placeholder={placeholder}
className="p-2 mt-1 w-full"
onReset={() => setSearchValue("")}
value={getValueToDisplayOnInput(valueSelectedOnAutoComplete)}
/>
<button
onClick={() => onSearch(searchValue)}
className={`flex justify-center items-center
m-2 py-2 px-4 rounded bg-white hover:bg-gray-100
`}
>
{searchIcon()}
</button>
</div>
<div className="flex flex-col">
{results.map((result) => (
<div
key={result.place_id}
className="flex items-center text-gray-800 bg-white hover:bg-gray-200 hover:cursor-pointer"
onClick={() => onSelect(result)}
>
<div className="mr-2">{pinIcon()}</div>
{result.display_name}
</div>
))}
</div>
</div>
);
};
Se você observar na demo do app, veremos que temos dois GeocoderInputs, então resolvi criar um componente chamado GeocoderForm, que vai englobar esses dois componentes e chamar o GeocoderService para buscar os resultados e gerenciar em cada componente:
import { useCallback, useState } from "react";
import { GeocoderResult } from "../types";
import { GeocoderInput } from "../GeocoderInput";
import { GeocoderService } from "../../services/GeocoderService";
import debounce from "lodash.debounce";
interface GeocoderFormProps {
onOriginSelectEvent: (value?: GeocoderResult) => void;
onDestinySelectEvent: (value?: GeocoderResult) => void;
}
export const GeocoderForm = ({
onOriginSelectEvent,
onDestinySelectEvent,
}: GeocoderFormProps) => {
const [originResults, setOriginResults] = useState<GeocoderResult[]>([]);
const [destinyResults, setDestinyResults] = useState<GeocoderResult[]>([]);
const [originValue, setOriginValue] = useState<string | GeocoderResult>();
const [destinyValue, setDestinyValue] = useState<string | GeocoderResult>();
const onSearchOrigin = async (value: string) => {
setOriginValue(value);
if (value) {
const results = await GeocoderService.getResults(value);
setOriginResults(results);
} else {
onOriginSelectEvent(undefined);
}
};
const onOriginSelect = (value: GeocoderResult) => {
onOriginSelectEvent(value);
setOriginValue(value);
setOriginResults([]);
};
const onSearchDestiny = async (value: string) => {
setDestinyValue(value);
if (value) {
const results = await GeocoderService.getResults(value);
setDestinyResults(results);
} else {
onDestinySelectEvent(undefined);
}
};
const onDestinySelect = (value: GeocoderResult) => {
onDestinySelectEvent(value);
setDestinyValue(value);
setDestinyResults([]);
};
return (
<div className="m-2 md:w-1/3">
<GeocoderInput
onSearch={onSearchOrigin}
placeholder="Digite a origem"
valueSelectedOnAutoComplete={originValue}
onSelect={onOriginSelect}
results={originResults}
/>
<GeocoderInput
onSearch={onSearchDestiny}
placeholder="Digite o destino"
valueSelectedOnAutoComplete={destinyValue}
onSelect={onDestinySelect}
results={destinyResults}
/>
</div>
);
};
Agora, criaremos o componente Infobox que exibirá a distância e a duração da rota:
interface InfoboxProps {
distance: string;
duration: string;
}
export const Infobox = ({ distance, duration }: InfoboxProps) => {
return (
<div
className={`
fixed
z-20
bottom-0
text-white text-lg
flex flex-col justify-center items-center rounded
`}
>
<div className="flex flex-col bg-gray-500 items-center w-screen">
<label>
<b>Time:</b> {duration}
</label>
<label>
<b>Distance:</b> {distance}
</label>
</div>
</div>
);
};
E o último componente que usaremos, será o select para selecionar o modo de viagem, o componente Modal. Receberá apenas o callback do modal selecionado:
interface ModalProps {
onModalSelect: (event: any) => void;
}
export const Modal = ({ onModalSelect }: ModalProps) => {
return (
<div className="relative z-20">
<select
className="flex justify-center items-center h-10 w-56 text-lg ml-2"
onChange={onModalSelect}
defaultValue={google.maps.TravelMode.DRIVING}
>
<option value={google.maps.TravelMode.DRIVING}>Car</option>
<option value={google.maps.TravelMode.BICYCLING}>Bike</option>
<option value={google.maps.TravelMode.WALKING}>Walking</option>
</select>
</div>
);
};
Agora lembra do nosso App.tsx? Vamos substituir por todos os componentes que criamos e gerenciar o estado nesse arquivo principal, passando-os como prop-drilling:
import Map, { Layer, Marker, Source, useMap } from "react-map-gl";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { useEffect, useMemo, useState } from "react";
import { Loader } from "google-maps";
import polyline from "@mapbox/polyline";
import { GeocoderForm } from "./components/GeocoderForm";
import { GeocoderResult } from "./components/types";
import { Infobox } from "./components/Infobox";
import { Modal } from "./components/Modal";
import { pinIconMap } from "./components/icons";
const GOOGLE_API_KEY = import.meta.env.VITE_GOOGLE_API_KEY;
const MAPTILER_API_KEY = import.meta.env.VITE_MAPTILER_API_KEY;
const loader = new Loader(GOOGLE_API_KEY);
const google = await loader.load();
const directionsService = new google.maps.DirectionsService();
const MAPS_DEFAULT_LOCATION = {
latitude: -22.9064,
longitude: -47.0616,
zoom: 6,
};
export const App = () => {
const [originLat, setOriginLat] = useState<number>();
const [originLng, setOriginLng] = useState<number>();
const [destinyLat, setDestinyLat] = useState<number>();
const [destinyLng, setDestinyLng] = useState<number>();
const [distance, setDistance] = useState("");
const [duration, setDuration] = useState("");
const [modal, setModal] = useState(google.maps.TravelMode.DRIVING);
const [route, setRoute] = useState<any>();
useEffect(() => {
if (destinyLat && destinyLng && originLat && originLng) {
const start = new google.maps.LatLng(originLat, originLng);
const end = new google.maps.LatLng(destinyLat, destinyLng);
var request = {
origin: start,
destination: end,
travelMode: modal,
};
directionsService.route(request, function (result, status) {
if (status == "OK") {
setDuration(result.routes[0].legs[0].duration.text);
setDistance(result.routes[0].legs[0].distance.text);
setRoute(polyline.toGeoJSON(result.routes[0].overview_polyline));
}
});
}
}, [destinyLat, destinyLng, originLat, originLng, modal]);
const mapTilerMapStyle = useMemo(() => {
return `https://api.maptiler.com/maps/basic-v2/style.json?key=${MAPTILER_API_KEY}`;
}, []);
const onOriginSelected = (value: GeocoderResult | undefined) => {
setOriginLat(value ? parseFloat(value.lat) : undefined);
setOriginLng(value ? parseFloat(value.lon) : undefined);
if (!value) {
setRoute(undefined);
}
};
const onDestinySelected = (value: GeocoderResult | undefined) => {
setDestinyLat(value ? parseFloat(value.lat) : undefined);
setDestinyLng(value ? parseFloat(value.lon) : undefined);
if (!value) {
setRoute(undefined);
}
};
const onModalSelect = (event: any) => {
setModal(event.target.value);
};
return (
<>
<Map
initialViewState={{
...MAPS_DEFAULT_LOCATION,
}}
style={{
width: "100wh",
height: "100vh",
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
}}
hash
mapLib={maplibregl}
mapStyle={mapTilerMapStyle}
>
{originLat && originLng && (
<Marker longitude={originLng} latitude={originLat} anchor="bottom">
{pinIconMap()}
</Marker>
)}
{destinyLat && destinyLng && (
<Marker longitude={destinyLng} latitude={destinyLat} anchor="bottom">
{pinIconMap()}
</Marker>
)}
{route && (
<>
<Source id="polylineLayer" type="geojson" data={route}>
<Layer
id="lineLayer"
type="line"
source="my-data"
layout={{
"line-join": "round",
"line-cap": "round",
}}
paint={{
"line-color": "rgba(3, 170, 238, 0.5)",
"line-width": 5,
}}
/>
</Source>
<Infobox duration={duration} distance={distance} />
</>
)}
</Map>
<div className="">
<GeocoderForm
onOriginSelectEvent={onOriginSelected}
onDestinySelectEvent={onDestinySelected}
/>
<Modal onModalSelect={onModalSelect} />
</div>
</>
);
};
Resultado da aplicação
FINALMENTE terminamos esta aplicação!!!
Vejamos em execução:
Incrível, não?
Então, finalmente terminamos este aplicativo de demonstração simples, mas o resultado é incrível.
Em resumo: criamos uma aplicação que com base em duas localizações, irá traçar a melhor rota (segundo a google) entre esses dois pontos. Além de que, caso fornecemos um modo de viagem diferente, a rota, distância e tempo irão mudar.
Espero ter agregado algum conhecimento para você chegar até aqui.
Alguns links:
O repositório: https://github.com/kevinuehara/map-router
O aplicativo (implantado na vercel): https://map-router-app.vercel.app/
Contatos:
E-mail Pessoal: uehara.kevin@gmail.com
E-mail Ifood: kevin.uehara@ifood.com.br
Github: https://github.com/kevinuehara
Instagram: https://www.instagram.com/uehara_kevin/
Twitter: https://twitter.com/ueharaDev
Canal no Youtube: https://www.youtube.com/channel/UC6VSwt_f9yCdvEd944aQj1Q
E isso é tudo, pessoal!!! muito obrigado