Google Map Viewer in React

2024-05-19
By: O Wolfson

In this article, we'll explore how to create a Google Map Viewer using React and Next.js. This app allows users to input a Google Maps link, which then displays the corresponding location on a map. You can check out the demo here.

Get the source code on GitHub.

Project Structure

Our project consists of a main page (app/page.tsx) and four components located in the components/maps folder.

plaintext
app/
  page.tsx
components/
  maps/
    google-map.tsx
    map-component.tsx
    map-provider.tsx
    map-utils.ts

Let's walk through these components and their functionalities in a logical order.

1. map-utils.ts

This utility module handles URL expansion and extraction of latitude and longitude coordinates from Google Maps links. It contains two main functions:

  • expandUrl: Expands shortened URLs to their full version.
  • extractLatLong: Extracts latitude and longitude coordinates from either DMS (Degrees, Minutes, Seconds) or decimal formats.
tsx
"use server";

import axios from "axios";

export const expandUrl = async (shortUrl: string): Promise<string | null> => {
  try {
    const response = await axios.get(shortUrl, {
      maxRedirects: 0,
      validateStatus: (status) => status === 302, // Handle redirect
    });
    return response.headers.location; // This contains the expanded URL
  } catch (error) {
    console.error("Error expanding URL:", error);
    return null;
  }
};

const dmsToDecimal = (
  degrees: number,
  minutes: number,
  seconds: number,
  direction: string
): number => {
  const decimal = degrees + minutes / 60 + seconds / 3600;
  return direction === "S" || direction === "W" ? -decimal : decimal;
};

export const extractLatLong = (
  url: string
): { lat: number; lng: number } | null => {
  // Decode the URL to handle URL-encoded characters
  const decodedUrl = decodeURIComponent(url);
  // console.log("Decoded URL:", decodedUrl);

  // Regex to match DMS coordinates
  const dmsPattern = /(\d{1,3})°(\d{1,2})'(\d{1,2}(?:\.\d+)?)"?([NSEW])/g;
  // Regex to match decimal coordinates
  const decimalPattern = /@(-?\d+\.\d+),(-?\d+\.\d+)/;
  // Regex to match accurate 3d/4d coordinates
  const accurateDecimalPattern = /3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/;

  let match;
  let lat, lng;

  // Try to extract accurate 3d/4d coordinates
  match = accurateDecimalPattern.exec(decodedUrl);
  if (match) {
    return {
      lat: parseFloat(match[1]),
      lng: parseFloat(match[2]),
    };
  }

  // Try to extract DMS coordinates for latitude and longitude
  const dmsMatches = decodedUrl.match(dmsPattern);
  if (dmsMatches && dmsMatches.length >= 2) {
    const latMatch = dmsMatches[0].match(
      /(\d{1,3})°(\d{1,2})'(\d{1,2}(?:\.\d+)?)"?([NSEW])/
    );
    const lngMatch = dmsMatches[1].match(
      /(\d{1,3})°(\d{1,2})'(\d{1,2}(?:\.\d+)?)"?([NSEW])/
    );

    if (latMatch && lngMatch) {
      lat = dmsToDecimal(
        parseInt(latMatch[1]),
        parseInt(latMatch[2]),
        parseFloat(latMatch[3]),
        latMatch[4]
      );
      lng = dmsToDecimal(
        parseInt(lngMatch[1]),
        parseInt(lngMatch[2]),
        parseFloat(lngMatch[3]),
        lngMatch[4]
      );

      return { lat, lng };
    }
  }

  // Fallback to extract decimal coordinates if accurate coordinates are not found
  match = decimalPattern.exec(decodedUrl);
  if (match) {
    return {
      lat: parseFloat(match[1]),
      lng: parseFloat(match[2]),
    };
  }

  return null;
};

2. map-provider.tsx

The MapProvider component loads the Google Maps JavaScript API and provides it to the rest of the app. This component uses the useJsApiLoader hook from the @react-google-maps/api library to load the API asynchronously.

tsx
"use client";

import { Libraries, useJsApiLoader } from "@react-google-maps/api";
import { ReactNode } from "react";

const libraries = ["places", "drawing", "geometry"];

export function MapProvider({ children }: { children: ReactNode }) {
  const { isLoaded: scriptLoaded, loadError } = useJsApiLoader({
    googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API as string,
    libraries: libraries as Libraries,
  });

  if (loadError) return <p>Encountered error while loading google maps</p>;

  if (!scriptLoaded) return <p>Map Script is loading ...</p>;

  return children;
}

3. map-component.tsx

The MapComponent is the core component that renders the map, marker, and info window. It also provides an input form for users to enter a Google Maps link, title, and address.

tsx
"use client";

import React, { useState, useEffect } from "react";
import { GoogleMap, Marker, InfoWindow } from "@react-google-maps/api";
import Link from "next/link";
import { expandUrl, extractLatLong } from "./map-utils";

const defaultMapOptions = {
  zoomControl: true,
  tilt: 0,
  gestureHandling: "auto",
  mapTypeId: "roadmap",
};

const defaultMapZoom = 18;

const info = {
  title: "The Empire State Building",
  address: "20 W 34th St., New York, NY 10001, United States",
  link: "https://maps.app.goo.gl/qz2zoCrJpmjH7Pmk7",
};

export interface MapCenter {
  lat: number;
  lng: number;
}

export const defaultMapContainerStyle = {
  height: "300px",
  borderRadius: "15px 15px 15px 15px",
};

const MapComponent: React.FC = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [mapCenter, setMapCenter] = useState<MapCenter>();
  const [mapInfo, setMapInfo] = useState(info);
  const [tempMapInfo, setTempMapInfo] = useState(info);
  const [isLoading, setIsLoading] = useState(false);
  const [isSuccess, setIsSuccess] = useState(false);

  useEffect(() => {
    const fetchCoordinates = async () => {
      const expandedUrl = await expandUrl(mapInfo.link);
      if (expandedUrl) {
        const coords = await extractLatLong(expandedUrl);
        if (coords) {
          setMapCenter(coords);
        }
      }
    };

    if (mapInfo.link) {
      fetchCoordinates();
    }
  }, [mapInfo.link]);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setIsLoading(true);
    setIsSuccess(false);
    setMapInfo(tempMapInfo);
    setIsLoading(false);
    setIsSuccess(true);
    setTimeout(() => setIsSuccess(false), 1500);
  };

  return (
    <div className="flex flex-col gap-4">
      <GoogleMap
        mapContainerStyle={defaultMapContainerStyle}
        center={mapCenter}
        zoom={defaultMapZoom}
        options={defaultMapOptions}
      >
        {mapCenter && (
          <>
            <Marker position={mapCenter} onClick={() => setIsOpen(true)} />
            {isOpen && (
              <InfoWindow
                position={mapCenter}
                onCloseClick={() => setIsOpen(false)}
              >
                <div>
                  <div className="text-black flex flex-col gap-1">
                    <div className="font-semibold text-lg -mb-1">
                      {mapInfo.title}
                    </div>
                    <div>{mapInfo.address}</div>
                    <Link
                      target="_blank"
                      href={mapInfo.link}
                      className="font-semibold text-blue-500 hover:underline"
                    >
                      View on Google Maps
                    </Link>
                  </div>
                </div>
              </InfoWindow>
            )}
          </>
        )}
      </GoogleMap>
      <form onSubmit={handleSubmit} className="flex flex-col gap-2">
        <div>
          <label htmlFor="mapLink" className="block text-sm p-1">
            Map Link
          </label>
          <input
            id="mapLink"
            type="text"
            value={tempMapInfo.link}
            onChange={(e) =>
              setTempMapInfo({ ...tempMapInfo, link: e.target.value })
            }
            placeholder="Enter the map link"
            className="border border-gray-300 rounded-md p-2 w-full text-black"
          />
        </div>
        <div>
          <label htmlFor="mapTitle" className="block text-sm p-1">
            Map Title
          </label>
          <input
            id="mapTitle"
            type="text"
            value={tempMapInfo.title}
            onChange={(e) =>
              setTempMapInfo({ ...tempMapInfo, title: e.target.value })
            }
            placeholder="Enter the map title"
            className="border border-gray-300 rounded-md p-2 w-full text-black"
          />
        </div>
        <div>
          <label htmlFor="mapAddress" className="block text-sm p-1">
            Map Address
          </label>
          <input
            id="mapAddress"
            type="text"
            value={tempMapInfo.address}
            onChange={(e) =>
              setTempMapInfo({ ...tempMapInfo, address: e.target.value })
            }
            placeholder="Enter the map address"
            className="border border-gray-300 rounded-md p-2 w-full text-black"
          />
        </div>
        <button
          type="submit"
          className="mt-4 bg-blue-500 text-white p-2 rounded-md active:bg-blue-400"
          disabled={isLoading}
        >
          {isLoading ? "Submitting..." : "Submit"}
        </button>
        {isSuccess && (
          <div className="text-green-500 mt-2">
            Map information updated successfully!
          </div>
        )}
      </form>
    </div>
  );
};

export { MapComponent };

4. google-map.tsx

The GoogleMap component serves as a container for the MapProvider and MapComponent. It ensures that the Google Maps API is loaded before rendering the map.

tsx
import React from "react";
import { MapProvider } from "@/components/maps/map-provider";
import { MapComponent } from "@/components/maps/map-component";

function GoogleMap() {
  return (
    <div>
      <MapProvider>
        <MapComponent />
      </MapProvider>
    </div>
  );
}

export default GoogleMap;

Main Page (app/page.tsx)

Finally, the main page of our Next.js app (app/page.tsx) imports and uses the GoogleMap component.

tsx
import GoogleMap from "@/components/maps/google-map";

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="w-full">
        <GoogleMap />
      </div>
    </main>
  );
}

Conclusion

By structuring our components logically and leveraging the power of React and Next.js, we have created a robust Google Map Viewer application. The MapComponent handles user input and displays the map, while MapProvider ensures the Google Maps API is loaded correctly. The map-utils.ts module provides essential utility functions for URL and coordinate handling.

Feel free to explore the demo and try out different Google Maps links. Happy coding!