DEV Community

Cover image for Creating Geographic Solutions with Maps in Frontend
Kevin Toshihiro Uehara
Kevin Toshihiro Uehara

Posted on • Updated on

Creating Geographic Solutions with Maps in Frontend

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...

Give Two Locations will be render a route

Summary

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)

person sweating with so much stuff

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

Technologys architecture

Components which we will create

Image description

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
Enter fullscreen mode Exit fullscreen mode

And installing the dependencies (I will use yarn)

cd map-router
yarn
Enter fullscreen mode Exit fullscreen mode

Now, we can start the app (easy peasy)

yarn dev
Enter fullscreen mode Exit fullscreen mode

Initial project running

And we will have this directory of files created:

Directory and files on VS CODE

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

Image description

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}
Enter fullscreen mode Exit fullscreen mode

And now install the dependencies that we will use:

yarn add axios google-maps mapbox-gl maplibre-gl react-map-gl @mapbox/polyline
Enter fullscreen mode Exit fullscreen mode

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

yarn add -D @types/mapbox__polyline
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The result it will be:

Map rendered on map

WOOOOW Amazing! We have a MAP on our app!

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

Image description

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

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Result Application

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

yarn dev
Enter fullscreen mode Exit fullscreen mode

Final Application gif

AMAZING, ISN'T IT?

Spongebob sweating

So, finally we finish this app simple demo app, but the result it is incredible.
I hope I have added some knowledge for you to get here.

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
Instagram: https://www.instagram.com/uehara_kevin/

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

Top comments (0)