Creating Geographic Solutions with Maps in Frontend

Kevin Uehara
iFood Engineering
11 min readJul 24, 2023

--

Hi people!!! In this article, I will show a demo app using map, creating the best route between two locations, and will it be possible to choose three types of travel mode on front-end. INCREDIBLE, YEAH? Magic or technology ? Let’s see on…

Summary

  • Introduction
  • Technologies
  • Architecture
  • Show me the Code
  • Result Application

Introduction

Working with maps on the frontend was something that I had never worked with in particular and I thought it was something much more complex (I still think so, but much less). Dealing with maps, polygon rendering, spatial data all being processed in the client was something I kept asking myself: how was this done? Magic? No! It’s technology…

Maybe it’s worth talking about the challenges I faced to create the solutions we have on the iFood frontend, in another article. But here I want to be much more practical and hands on in building a demo application.

The main idea is to present the app and offer an overview of the tools for building an application using maps. I will show how to integrate a map into the frontend and based on two locations, display the best route between them. It will still be possible to choose the type of modal (travel mode), for example, bicycle, car or walking, and the type will influence the route that will be provided. Given the route, the distance and time to be traveled will be calculated.

The goal is to bring some of the tools that deal with maps and are used in the market. The application was built using the Vite + React library as front-end tooling, Typescript, MapLibre, Mapbox, Tailwind, Google Maps API and Nominatim.

Technologies

As I mentioned earlier (spoilers) I’m going to use React with Typescript as a frontend library, in addition to using Vite as a tool to manage packages/dependencies and create the project. By itself, Vite (use rollup) would be worth another article talking only about it, but in order not to deviate from the purpose of this article, at the end I will provide links to each documentation. So, instead of using Create React App (CRA) I will be using Vite, which will bring us everything we need, speed, structure its lean architecture.

To make our lives easier, I will also be using Tailwind to style our application, bringing our styles in a simple and easy-to-apply way.

I will also be using the Maplibre open-source library for map rendering. The React Map GL that will provide us with several React components focused on interactions on the map. Also, I will be using MapTiler as a map style. MapTiler will provide us with a more beautiful and cleaner map, being free up to a limit of requests. As it is a demo and example application, we will not worry about that, but be aware of this point (remembering that there are open-source map styles from Open Street Maps, commonly known as OSM, that you can use).

For Geocoding, suggesting addresses as the user types and transforming the location into a point (latitude and longitude), I will be using Nominatim. It is an open-source and free tool.

And finally, for the calculation and suggestion of the route, I will be using the Google Maps API itself. It is also worth mentioning that there is a limit of requests to use it for free and as it is a demo we will not worry about that. But for the sake of curiosity, there is another open-source tool called OSRM (Open Source Route Machine), which also calculates and suggests routes, based on OSM (Open Street Maps) maps created in C++.

In summary we will use:

  • Vite (Front-end Tooling)
  • React + Typescript
  • Tailwind (CSS Framework)
  • Google Maps API (To create the route)
  • MapLibre (Lib to render the map)
  • MapTiler (Style Map Vision Provider)
  • React Map GL (React components to use on map)
  • Nominatim (API for geocoding)

Architecture

for this application I did not used some state manager, for example Context API, Jotai, Redux or Recoil. Just using prop-drilling, because the component hierarchy is small and simple, it is not necessary to use a global state manager

Before show the code, let’s see some vision of architecture of application that we will build:

Vision of technology of the project

Components which we will create

We talk a lot… SO LET’s TO THE CODE

Show me the code

First let’s create the vite project using the command:

yarn create vite map-router --template react-ts

And installing the dependencies (I will use yarn)

cd map-router
yarn

Now, we can start the app (easy peasy)

yarn dev

And we will have this directory of files created:

Let’s config the Tailwind (nothing new, just follow the documentation)

As I mentioned, the Google Maps API and MapTiler require registration and API Keys (as I commented, this is a app demo, so nothing will be charged after many requests). So, I will create a .env containing the two API keys:

VITE_MAPTILER_API_KEY={your api key of maptiler}
VITE_GOOGLE_API_KEY={your api key of google cloud project}

And now install the dependencies that we will use:

yarn add axios google-maps mapbox-gl maplibre-gl react-map-gl @mapbox/polyline

And the just one dev dependency @types for the mapbox/polyline (It will create the route on map)

yarn add -D @types/mapbox__polyline

Lets change the main.tsx to add the Provider of 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>
);

Now, let’s delete the content of App.tsx and replace it with this code:

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>
</>
);
};

The result it will be:

WOOOOW Amazing! We have a MAP on our app!

Now let’s create some directories for our components and the service:

First Let’s create our service with that will integrate with Nominatim to search some location, and we will receive as response a point (lat/long). This service will be called as index.tsx on 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;
};

The GeocoderResult type we will create on componets/types.ts:

export interface GeocoderResult {
place_id: string;
display_name: string;
lat: string;
lon: string;
}

export type SearchValueType = GeocoderResult | string | undefined;

In this application we will use some icons, so I decided to create JSX icons, like pin and seach icons on 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>
);
};

Our first component will be the GeocoderInput. It will receive a placeholder, results of the Nominatim, value selected, onSelect callback and onSearch callback.

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>
);
};

If you see on the demo of app, we will see that we have two GeocoderInputs, so I decided to create a component called GeocoderForm, that will englobe this two components and call the GeocoderService to seach the results and manage on each component:

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>
);
};

Now, we will create the Infobox component which will display the distance and duration of route:

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>
);
};

And the last component that we will use, it will be the select of trave mode, the Modal component. Will receive only the callback of the modal selected:

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>
);
};

Now remember of our App.tsx? Let’s replace with all components that we created and manage the state:

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>
</>
);
};

Result Application

FINALLY we finish this application!!!
Let’s see running:

AMAZING, ISN’T IT?

Some links:
The repository: https://github.com/kevinuehara/map-router
The App (deployed on vercel): https://map-router-app.vercel.app/

Contacts:
E-mail: uehara.kevin@gmail.com
Github: https://github.com/kevinuehara

Twitter: https://twitter.com/ueharaDev
Instagram: https://www.instagram.com/uehara_kevin/

Youtube Channel: https://www.youtube.com/@ueharakevin/

That’s all, folks!!! thank you so much

--

--