Mapas com React usando Leaflet

Neste post vamos desenvolver uma página web para demonstrar, na prática, a integração de Mapas em uma aplicação com React usando Leaflet.

Alguns pontos que vamos abordar:

  • Geolocalização;
  • Consumo de API;
  • Input com Autocomplete usando React-Select;
  • Integração com Mapas;
  • Estilização do Mapa e Marcadores.

Leaflet é uma biblioteca JavaScript open-source para trabalhar com Mapas em aplicações web e mobile. Pode ser simplesmente integrada a um site usando apenas HTML, CSS e JavaScript.

Podemos também integrar a Leaflet ao React com a biblioteca React Leaflet, que tem suporte ao TypeScript sendo bastante simples de utilizar. Ambas serão utilizadas em nossa aplicação de demonstração.

Somando todas essas tecnologias e conceitos, no final deste post vamos ter desenvolvido o app Entregas. Vai ser assim:

Fluxo da aplicação Entregas - Clique em HD para melhorar a qualidade do Gif.

Conforme podemos observar na animação acima, quando o usuário digita o endereço, sugestões de locais semelhantes começam a aparecer no autocomplete. Quando algum endereço é selecionado, o pin aparece centralizado no mapa, mostrando a localidade escolhida.

Quando o usuário envia o formulário, o pin 📍 é atualizado para um ícone de pacote 📦. Para acessar os dados da entrega basta clicar no ícone — esse é o fluxo completo :)

📝 Pré-requisitos

Sempre queremos entregar a melhor experiência para nossa audiência.

Para uma melhor experiência com a leitura, você precisa entender o básico de:

🔰 Iniciando o Projeto - CRA

Para criar um projeto React com TypeScript, execute o comando com Yarn:

yarn create react-app blog-react-maps-leaflet --template typescript

Ou com Npx (npm)

npx create-react-app blog-react-maps-leaflet --template typescript

Agora é só acessar o projeto para começar a codar:

cd blog-react-maps-Leaflet && code .

🌍  Instalando e configurando as bibliotecas

Para trabalhar com mapas instalamos as libs:

yarn add leaflet react-leaflet

A lib react-select vai auxiliar com autocomplete no input de endereço, e o uuid vai gerar um ID:

yarn add react-select uuid

Instalando as tipagens das libs:

yarn add -D @types/react-leaflet @types/react-leaflet @types/react-select

📂 Configurando a variável de ambiente

Sempre que formos trabalhar com APIs de mapas vamos precisar de um Token de acesso com o provedor. Mais uma vez estaremos usando o Mapbox.

É bem simples de criar a conta e pegar o access_token.

Crie um arquivo .env e adicione uma variável:

REACT_APP_ACCESS_TOKEN_MAP_BOX=SEU_ACCESS_TOKEN_MAP_BOX_AQUI

No arquivo .gitignore adicione o .env para não ser enviado ao Github.

No projeto criado com create-react-app é obrigatório a variável começar com REACT_APP_

⚠️  Essa variável vai ficar protegida apenas no código, porém fica visível nas requisições, um usuário avançado consegue acessá-la no console/network do navegador.

📍 Configurando a API de Geolocalização.

Para simplificar, não vou criar várias pastas para organizar o projeto, ao invés disso vou manter todos os arquivos dentro de src.

Na pasta src crie um arquivo apiMapBox.ts

const ACCESS_TOKEN_MAP_BOX = `access_token=${process.env.REACT_APP_ACCESS_TOKEN_MAP_BOX}`

export const fetchLocalMapBox = (local: string) =>
fetch(
  `https://api.mapbox.com/geocoding/v5/mapbox.places/${local}.json?${ACCESS_TOKEN_MAP_BOX}`
)
  .then(response => response.json())
  .then(data => data);

O código acima usa a Fetch API do JavaScript para buscar o local que o usuário informar e retorna os dados.

Exemplo:

https://api.mapbox.com/geocoding/v5/mapbox.places/Brasil.json?access_token=MAP_BOX_ACCESS_TOKEN

Resultado:

Para acessar os endpoints do Mapbox, clique aqui.

💅 Estilizando a aplicação

No arquivo src/index.css, substitua o conteúdo atual pelo código abaixo.

Basicamente reseta as margens, padding, outline, box-sizing e define a fonte Nunito e um tamanho padrão para os elementos HTML:

* {
  margin: 0;
  padding: 0;
  outline: 0;
  box-sizing: border-box;
}

body,
input,
button,
textarea {
  font: 600 18px Nunito, sans-serif;
}

No arquivo index.html adicione a fonte Nunito do projeto:

<link
     href="<https://fonts.googleapis.com/css2?family=Nunito:wght@600;700;800&display=swap>"
     rel="stylesheet"
/>

Arquivo index.html completo:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link
      href="<https://fonts.googleapis.com/css2?family=Nunito:wght@600;700;800&display=swap>"
      rel="stylesheet"
    />

    <title>Entregas</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>

No arquivo src/App.css, substitua o conteúdo:

#page-map {
  width: 100vw;
  height: 100vh;
}

form.landing-page-form {
  width: 500px;

  background: #ffffff;
  border: 1px solid #d3e2e5;
  border-radius: 30px;

  padding: 20px 40px;

  position: absolute;
  top: 30px;
  left: 40px;

  z-index: 1;
}

#page-map .leaflet-container {
  z-index: 0;
}

form.landing-page-form fieldset {
  border: 0;
}

form.landing-page-form fieldset legend {
  width: 100%;

  font-size: 32px;
  line-height: 34px;
  color: #5c8599;
  font-weight: 700;

  border-bottom: 1px solid #d3e2e5;
  margin-bottom: 20px;
  padding-bottom: 20px;
}

form.landing-page-form .input-block + .input-block {
  margin-top: 24px;
}

form.landing-page-form .input-block label {
  display: flex;
  color: #8fa7b3;
  margin-bottom: 8px;
  line-height: 24px;
}

form.landing-page-form .input-block label span {
  font-size: 14px;
  color: #8fa7b3;
  margin-left: 24px;
  line-height: 24px;
}

form.landing-page-form .input-block input {
  width: 100%;
  background: #f5f8fa;
  border: 1px solid #d3e2e5;
  border-radius: 20px;
  outline: none;
  color: #5c8599;
}

form.landing-page-form .input-block input {
  height: 44px;
  padding: 0 16px;
}

form.landing-page-form button.confirm-button {
  margin-top: 34px;

  width: 100%;
  height: 64px;
  border: 0;
  cursor: pointer;
  background-color: #4254f5;
  border-radius: 20px;
  color: #ffffff;
  font-weight: 800;

  transition: background-color 0.2s;
}

form.landing-page-form button.confirm-button:hover {
  background-color: #6c79f5;
}

/* Pop Up - Marker */

#page-map .map-popup .leaflet-popup-content-wrapper {
  background: rgba(255, 255, 255, 0.8);
  border-radius: 20px;
  box-shadow: none;
}

#page-map .map-popup .leaflet-popup-content h3 {
  color: #0089a5;
  font-size: 20px;
  font-weight: bold;
  margin: 8px 12px;
}

#page-map .map-popup .leaflet-popup-content p {
  color: #042f38;
  font-size: 12px;
  font-weight: bold;
  margin: 8px 12px;
  line-height: 15px;
}

#page-map .map-popup .leaflet-popup-tip-container {
  display: none;
}

/* Styling react select */

.filter__control {
  border-radius: 20px !important;

  width: 100% !important;
  background: #f5f8fa !important;
  border: 1px solid #d3e2e5 !important;
  border-radius: 20px !important;
  outline: none !important;
  color: #5c8599 !important;
}

.filter__option {
  background: #f5f8fa !important;
  color: #5c8599 !important;
}

.filter__option--is-focused {
  background: #d3e2e5 !important;
  color: #010101 !important;
}
  • Detalhes importantes sobre a estilização:

Se procurar no código alguma referência por leaflet-popup-content-wrapper você não vai encontrar. Utilizei o inspecionador de código do Navegador para saber quais classes o Leaflet usa para fazermos as customizações nelas.

A mesma coisa para a lib do react-select, no arquivo App.tsx que veremos a seguir. Adicionei classNamePrefix="filter", um prefixo que na DOM aparece, por isso tem o filter__control, por exemplo. Você não encontra uma referência a essa classe no código, ela só aparece quando a tela é montada no navegador.

Adicionamos !important para forçar a estilização e sobrepor os valores padrão que vem na biblioteca.

Último aspecto importante é que o formulário fica flutuando na tela enquanto o mapa fica por baixo. Para isso usamos o z-index e position: absolute para o formulário.

O restante da estilização é mais simples.

Por fim, para concluir a aplicação, vamos criar a lógica e adicionar os componentes necessários.

🗺️ Adicionando o Mapa e o Formulário

Adicione os arquivos SVG dentro da pasta srcpackage.svg | pin.svg

No arquivo src/App.tsx substitua o conteúdo por:


001: import "leaflet/dist/leaflet.css";
002: 
003: import React, { FormEvent, useState } from "react";
004: import { Map, Marker, Popup, TileLayer } from "react-leaflet";
005: import Leaflet from "leaflet";
006: import { v4 as uuidv4 } from "uuid";
007: 
008: import { fetchLocalMapBox } from "./apiMapBox";
009: import AsyncSelect from "react-select/async";
010: 
011: import mapPackage from "./package.svg";
012: import mapPin from "./pin.svg";
013: 
014: import "./App.css";
015: 
016: const initialPosition = { lat: -22.2154042, lng: -54.8331331 };
017: 
018: const mapPackageIcon = Leaflet.icon({
019:   iconUrl: mapPackage,
020:   iconSize: [58, 68],
021:   iconAnchor: [29, 68],
022:   popupAnchor: [170, 2],
023: });
024: 
025: const mapPinIcon = Leaflet.icon({
026:   iconUrl: mapPin,
027:   iconSize: [58, 68],
028:   iconAnchor: [29, 68],
029:   popupAnchor: [170, 2],
030: });
031: 
032: interface Delivery {
033:   id: string;
034:   name: string;
035:   address: string;
036:   complement: string;
037:   latitude: number;
038:   longitude: number;
039: }
040: 
041: type Position = {
042:   longitude: number;
043:   latitude: number;
044: };
045: 
046: function App() {
047:   const [deliveries, setDeliveries] = useState<Delivery[]>([]);
048: 
049:   const [position, setPosition] = useState<Position | null>(null);
050: 
051:   const [name, setName] = useState("");
052:   const [complement, setComplement] = useState("");
053:   const [address, setAddress] = useState<{
054:     label: string;
055:     value: string;
056:   } | null>(null);
057: 
058:   const [location, setLocation] = useState(initialPosition);
059: 
060:   const loadOptions = async (inputValue: any, callback: any) => {
061:     if (inputValue.length < 5) return;
062:     let places: any = [];
063:     const response = await fetchLocalMapBox(inputValue);
064:     response.features.map((item: any) => {
065:       places.push({
066:         label: item.place_name,
067:         value: item.place_name,
068:         coords: item.center,
069:         place: item.place_name,
070:       });
071:     });
072: 
073:     callback(places);
074:   };
075: 
076:   const handleChangeSelect = (event: any) => {
077:     console.log("changed", event);
078:     setPosition({
079:       longitude: event.coords[0],
080:       latitude: event.coords[1],
081:     });
082: 
083:     setAddress({ label: event.place, value: event.place });
084: 
085:     setLocation({
086:       lng: event.coords[0],
087:       lat: event.coords[1],
088:     });
089:   };
090: 
091:   async function handleSubmit(event: FormEvent) {
092:     event.preventDefault();
093: 
094:     if (!address || !name) return;
095: 
096:     setDeliveries([
097:       ...deliveries,
098:       {
099:         id: uuidv4(),
100:         name,
101:         address: address?.value || "",
102:         complement,
103:         latitude: location.lat,
104:         longitude: location.lng,
105:       },
106:     ]);
107: 
108:     setName("");
109:     setAddress(null);
110:     setComplement("");
111:     setPosition(null);
112:   }
113: 
114:   return (
115:     <div id="page-map">
116:       <main>
117:         <form onSubmit={handleSubmit} className="landing-page-form">
118:           <fieldset>
119:             <legend>Entregas</legend>
120: 
121:             <div className="input-block">
122:               <label htmlFor="name">Nome</label>
123:               <input
124:                 id="name"
125:                 placeholder="Digite seu nome"
126:                 value={name}
127:                 onChange={(event) => setName(event.target.value)}
128:               />
129:             </div>
130: 
131:             <div className="input-block">
132:               <label htmlFor="address">Endereço</label>
133:               <AsyncSelect
134:                 placeholder="Digite seu endereço..."
135:                 classNamePrefix="filter"
136:                 cacheOptions
137:                 loadOptions={loadOptions}
138:                 onChange={handleChangeSelect}
139:                 value={address}
140:               />
141:             </div>
142: 
143:             <div className="input-block">
144:               <label htmlFor="complement">Complemento</label>
145:               <input
146:                 placeholder="Apto / Nr / Casa..."
147:                 id="complement"
148:                 value={complement}
149:                 onChange={(event) => setComplement(event.target.value)}
150:               />
151:             </div>
152:           </fieldset>
153: 
154:           <button className="confirm-button" type="submit">
155:             Confirmar
156:           </button>
157:         </form>
158:       </main>
159: 
160:       <Map
161:         center={location}
162:         zoom={15}
163:         style={{ width: "100%", height: "100%" }}
164:       >
165:         {/* <TileLayer url="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png" /> */}
166:         <TileLayer
167:           url={`https://api.mapbox.com/styles/v1/mapbox/light-v10/tiles/256/{z}/{x}/{y}@2x?access_token=${process.env.REACT_APP_ACCESS_TOKEN_MAP_BOX}`}
168:         />
169: 
170:         {position && (
171:           <Marker
172:             icon={mapPinIcon}
173:             position={[position.latitude, position.longitude]}
174:           ></Marker>
175:         )}
176: 
177:         {deliveries.map((delivery) => (
178:           <Marker
179:             key={delivery.id}
180:             icon={mapPackageIcon}
181:             position={[delivery.latitude, delivery.longitude]}
182:           >
183:             <Popup
184:               closeButton={false}
185:               minWidth={240}
186:               maxWidth={240}
187:               className="map-popup"
188:             >
189:               <div>
190:                 <h3>{delivery.name}</h3>
191:                 <p>
192:                   {delivery.address} - {delivery.complement}
193:                 </p>
194:               </div>
195:             </Popup>
196:           </Marker>
197:         ))}
198:       </Map>
199:     </div>
200:   );
201: }
202: 
203: export default App;
204:
Código do App.tsx - Formulário e Mapa do site Entregas

Comentários por linha de código:

001 - importa a estilização da biblioteca leaflet;

002 a 014 - importa os arquivos e bibliotecas necessárias;

016 - declara a posição inicial que o mapa será renderizado (poderia usar API do navegador para pegar a localização atual do usuário, dentro do hook useEffect);

018 a 030 - customiza os marcadores package.svg e pin.svg para renderizar no mapa;

032 a 039 - define o contrato da interface Delivery com seus atributos;

041 a 044 - define o tipo Position que será explicado mais a frente seu caso de uso;

046 - começamos a criar a função App que irá conter toda a lógica do formulário e da renderização do mapa;

047 a 058 - são definidas as variáveis de estado do componente App usando useState.

  • deliveries: array que vai ter todos os dados preenchidos no formulário quando o mesmo for salvo;
  • position: objeto que representa uma coordenada geográfica, será utilizado para mostrar o marcador (pin.svg) quando o usuário digitar sua localização;
  • name, complement, address: armazenam os dados do formulário;
  • location: variável auxiliar para centralizar o mapa assim que usuário escolhe um endereço.

060 a 074 - define a função loadOptions que será disparada toda vez que o usuário digita algum endereço no input. Contém a implementação da busca do local (endereço) usando a API, retorna em formato de um array as opções para o autocomplete, o array é enviado pela função de retorno callback(places) que preencherá as options do select;

076 a 088 - define a função handleChangeSelect executada quando o usuário escolhe uma localização —  adicionamos um marcador com setPosition,  centralizamos o mapa com setLocation e adicionamos o endereço no estado com setAddress;

090 a 111 - define a função handleSubmit, disparada assim que usuário clica no botão confirmar submetendo o formulário.

  • Primeiro ela previne o comportamento padrão do navegador de fazer refresh na tela;
  • Verifica se usuário informou o endereço ou nome — se não, o fluxo é interrompido;
  • Adiciona no array de deliveries o objeto Delivery com suas propriedades. O id é gerado pelo uuidv4 em formato de string;
  • Por fim limpa os campos name, address, complement e reseta o position para remover o marcador pin.svg da tela.

113 a 199 - renderiza o mapa e o formulário na tela ;

132 a 139 - adiciona o componente AsyncSelect do React Select:

<AsyncSelect
     placeholder="Digite seu endereço..."
     classNamePrefix="filter"
     cacheOptions
     loadOptions={loadOptions}
     onChange={handleChangeSelect}
     value={address}
 />

Como o próprio nome sugere, ele irá se comportar de maneira assíncrona e, enquanto estiver buscando os dados da API para carregar o options do select, vai exibir um loading no componente.

159 a 197 - monta em tela o componente Map

  • 160 - Centraliza na posição inicial passando location
  • 165 - TileLayer é o desenho do mapa — deixei comentado a TileLayer padrão, que não é muito bonito;
  • 169 - Exibe o marcador da posição que o usuário escolheu no input do endereço, ele sai da tela assim que o usuário clica em confirmar do formulário;
  • 176 a 196 - a cada elemento do array exibe um marcador (package.svg) na posição definida no objeto delivery. Quando usuário clica no marcador aparece um PopUp com os dados sobre a entrega;

202 - exporta a função App que será importada no arquivo index.tsx

Fim da aplicação.

Esse é o resultado que temos no final da implementação:

Imagem do Site Entregas - final do post

Se quiser conferir o código fonte do projeto, clique aqui.

👊 Conclusão

Construímos uma aplicação de pequeno porte, porém com um conteúdo rico em informações sobre integração de mapas no React em 2020, usando React Hooks.

A escolha do Leaflet é interessante, porque é bem fácil de utilizar, a integração com React é muito boa e o suporte ao TypeScript funciona bem;

A escolha do react-select para autocomplete foi devido a sua UI bem bonita, fácil de customizar e a DX (experiência de desenvolvimento) ser agradável com uma documentação excelente.

A documentação do react-select recomenda que seja utilizado CSS-in-JS como exemplo o styled-components, mas, para fins didáticos e práticos, usei o CSS; React-select é bem performática, pesquisei outras libs para fazer o autocomplete mas algumas tinham dependência com Google Places do Google Maps, por exemplo. Como react-select é agnóstica, serviu bem para esse propósito.

Segue aqui alguns desafios para praticar um pouco mais:

🔥 Desafios:

  • Organizar os arquivos em pastas, separando os componentes;
  • Criar o botão reset do formulário para apagar os dados digitados e o marcador do mapa caso usuário já tenha escolhido um endereço;
  • Adicionar horário de entrega;
  • Criar um botão para abrir a rota no Google Maps a partir da localização da entrega;
  • Criar um botão no PopUp dos detalhes da entrega para remover a entrega e outro para editar;
  • Melhorar a UI do PopUp;
  • Replicar esse mesmo projeto usando o NextJS — bastante coisa deverão ser alteradas.

🚀 Fez algum desafio? Show! Agora você está ficando mais expert! 😎

Posta o link do repositório aqui nos comentários.

E aí, o que achou desse post?

Espero que tenha curtido! 💜

O aprendizado é contínuo e sempre haverá um próximo nível! 🚀