DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Alexander Dmitriev
Alexander Dmitriev

Posted on

How to create map like in airbnb with react and google-maps

Introduction

Disclaimer

  1. English is not my native language, so there may be mistakes in the text, but I'm sure that the code can say a thousand times more than any of my words
  2. I did not use react-map-libraries to make the solution as flexible and understandable as possible
  3. This is the most simple implementation without deep styling, clustering and global-storage.
  4. Source code is here - https://github.com/alex1998dmit/map_airbnb

Task

Let's assume that we need to create a map with displaying apartment cards on it as it is done on airbnb
The technology stack is next:

  • React & Typescript
  • Google map and @googlemaps/react-wrapper
  • MUI for style stuff

Implementation

Create our app

Everything is pretty trivial - you need to install the application using creat-react-app using TS

npx create-react-app my-app --template typescript
Enter fullscreen mode Exit fullscreen mode

Install dependecies

For the application to work, we need MUI, @googlemaps/react-wrapper

npm install --save @material-ui/core @material-ui/icons @googlemaps/react-wrapper
Enter fullscreen mode Exit fullscreen mode

Configure map

In this step we will integrate simple map to application. First of all you need to get google-map key - [https://developers.google.com/maps/documentation/javascript/get-api-key].
First of all let's create a Map component, which will be wrapper for google-maps.

import { useEffect, useRef, useState } from "react";
// we will use make styles for styling components, you can use another solutions (like css, sass or cssonjs
import { makeStyles } from "@material-ui/core";
// api mock data
import Apartments from "./apartments";

// Our component will receive center coords and zoom size in props
type MapProps = {
  center: google.maps.LatLngLiteral
  zoom: number
}

// map wrapper styles
const useStyles = makeStyles({
  map: {
    height: '100vh'
  }
})

function Map({ center, zoom }: MapProps) {
  const ref = useRef(null);
  const [map, setMap] = useState<google.maps.Map<Element> | null>(null)
  const classes = useStyles();

  useEffect(() => {
    // we need to save google-map object for adding markers and routes in future
    if (ref.current) {
      // here will connect map frame to div element in DOM by using ref hook
      let createdMap = new window.google.maps.Map(
        ref.current,
        {
          center,
          zoom,
          disableDefaultUI: true,
          clickableIcons: false
        }
      );
      setMap(createdMap)
    }
  }, [center, zoom]);

  // map will be connect to this div block
  return <div ref={ref} id="map" className={classes.map} />;
}

export default Map
Enter fullscreen mode Exit fullscreen mode

Then let's modify App.tsx with:

import React, { ReactElement } from 'react';
import { Wrapper, Status } from "@googlemaps/react-wrapper";
import Map from './Map'

// Here we can add views when map will loading or failure
const render = (status: Status): ReactElement => {
  if (status === Status.LOADING) return <h3>{status} ..</h3>;
  if (status === Status.FAILURE) return <h3>{status} ...</h3>;
  return <></>;
};

function App() {
  if (!process.env.REACT_APP_GOOGLE_KEY) {
    return <h2>Add google key</h2>
  }
  return (
    <div className="App">
      <Wrapper apiKey={process.env.REACT_APP_GOOGLE_KEY} render={render}>
        <Map center={{ lat: 55.753559, lng: 37.609218 }} zoom={11} />
      </Wrapper>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

And result is:

simple_map

Add custom overlays

The next step is to add a custom overlay. Why will we use overlays and not markers ? Because in my opinion it will be difficult to customize regular markers, according to the documentation we can change only icon image and label over it [https://developers.google.com/maps/documentation/javascript/custom-markers].
Let's create an OverlayContainer, which will be a wrapper for the components located on the map at certain coordinates.

import * as React from 'react'
import ReactDOM from 'react-dom';

// base function for creating DOM div node
function createOverlayElement() {
  const el = document.createElement('div');
  el.style.position = 'absolute';
  el.style.display = 'inline-block';
  el.style.width = '9999px';
  return el;
}

// Our OverlayComponent will recieve map, postion and children props - position is coords, map is google.map object and children is a component that will be render in overlay
export type Props = {
  map: google.maps.Map | null
  position: { lat: number, lng: number }
  children?: React.ReactChild
}

const OverlayContainer = (props: Props) => {
  const overlay = React.useRef<google.maps.OverlayView | null>(null)
  const el = React.useRef<Element | null>(null)

  // modified OverlayView from google.maps [https://developers.google.com/maps/documentation/javascript/reference/3.44/overlay-view?hl=en]
  class OverlayView extends window.google.maps.OverlayView {
    position: google.maps.LatLng | null = null;
    content: any = null;

    constructor(props: any) {
      super();
      props.position && (this.position = props.position);
      props.content && (this.content = props.content);
    }

    onAdd = () => {
      if (this.content) this.getPanes().floatPane.appendChild(this.content);
    };

    onRemove = () => {
      if (this.content?.parentElement) {
        this.content.parentElement.removeChild(this.content);
      }
    };

    draw = () => {
      if (this.position) {
        const divPosition = this.getProjection().fromLatLngToDivPixel(
          this.position
        );
        this.content.style.left = divPosition.x + 'px';
        this.content.style.top = divPosition.y + 'px';
      }
    };
  }

  React.useEffect(() => {
    return () => {
      if (overlay.current) overlay.current.setMap(null)
    }
  }, [])

  if (props.map) {
    el.current = el.current || createOverlayElement()
    overlay.current = overlay.current || new OverlayView(
      {
        position: new google.maps.LatLng(props.position.lat, props.position.lng),
        content: el.current
      }
    )
    overlay.current.setMap(props.map)
    return ReactDOM.createPortal(props.children, el.current);
  }
  return null
}

export default OverlayContainer
Enter fullscreen mode Exit fullscreen mode

Creating Map Points and Apartment Cards

I will create a simple apartment card by using MUI-core and MUI-icons [https://material-ui.com/ru/components/cards/] [https://material-ui.com/ru/components/material-icons/].
Let's create ApartmentCard:

import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardActionArea from '@material-ui/core/CardActionArea';
import CardContent from '@material-ui/core/CardContent';
import CardMedia from '@material-ui/core/CardMedia';
import Typography from '@material-ui/core/Typography';
import AspectRatioIcon from '@material-ui/icons/AspectRatio';
import { Grid, IconButton } from '@material-ui/core';
import MeetingRoomIcon from '@material-ui/icons/MeetingRoom';
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import CloseIcon from '@material-ui/icons/Close';

const useStyles = makeStyles({
  root: {
    maxWidth: 230,
    position: 'relative',
    zIndex: 1001,
  },
  media: {
    height: 100,
  },
  close: {
    position: 'absolute',
    left: 0,
    top: 0,
    zIndex: 1001,
    background: 'white',
    width: '25px',
    height: '25px'
  }
});

type ApartmentCardProps = {
  image: string
  address: string
  area: number
  rooms_number: number
  floor: number
  floor_count: number
  rent: number
  handleClose: () => void
}

export default function ApartmentCard(props: ApartmentCardProps) {
  const classes = useStyles();
  return (
    <Card className={classes.root}>
       <IconButton className={classes.close} aria-label="close" onClick={props.handleClose}>
        <CloseIcon />
      </IconButton>
      <CardActionArea>
        <CardMedia
          className={classes.media}
          image={props.image}
          title="Contemplative Reptile"
        />
        <CardContent>
          <Typography variant="body2" component="h2">
            {props.address}
          </Typography>
          <Grid container spacing={1}>
            <Grid item container xs={6} spacing={1} alignItems='center'>
              <Grid item xs={8}><AspectRatioIcon /></Grid>
              <Grid item xs={4}>{props.area}</Grid>
            </Grid>
            <Grid item container xs={6} spacing={1} alignItems='center'>
              <Grid item xs={8}><MeetingRoomIcon /></Grid>
              <Grid item xs={4}>{props.rooms_number}</Grid>
            </Grid>
            <Grid item container xs={6} spacing={1} alignItems='center'>
              <Grid item xs={8}><KeyboardArrowUpIcon /></Grid>
              <Grid item xs={4}>{props.floor}/{props.floor_count}</Grid>
            </Grid>
            <Grid item container xs={12} spacing={1} alignItems='center' justifyContent="center">
              <Typography variant="body2" style={{ fontWeight: 600 }}>{props.rent} $</Typography>
            </Grid>
          </Grid>
          </Typography> */}
        </CardContent>
      </CardActionArea>
    </Card>
  );
}
Enter fullscreen mode Exit fullscreen mode

And ApartmentPoint:

import { makeStyles } from "@material-ui/styles"

type ApartmentPonitProps = {
  price: number
  onClick: () => void
}

const styles = makeStyles({
  root:{
    background: 'white',
    borderRadius: '12px',
    padding: '8px',
    width: '60px',
    zIndex: 1000,
    position: 'relative'
  }
})

const ApartmentPoint = (props: ApartmentPonitProps) => {
  const classes = styles()
  return (
    <div className={classes.root} onClick={props.onClick}>
      {props.price} $
    </div>
  )
}

export default ApartmentPoint
Enter fullscreen mode Exit fullscreen mode

We will use MapPoint like wrapper that will render ApartmentPoint or ApartmentCard:

import { useEffect, useRef, useState } from "react"
import ApartmentCard from "./ApartmentCard"
import ApartmentPoint from "./ApartmentPoint"

type MapPointProps = {
  image: string
  address: string
  area: number
  rooms_number: number
  floor: number
  floor_count: number
  rent: number
}

const MapPoint = (props: MapPointProps) => {
  const [opened, setIsOpened] = useState<boolean>(false)
  const handleOnOpen = () => setIsOpened(true)
  const handleOnClose = () => setIsOpened(false)
  const containerRef = useRef<HTMLDivElement>(null)

// Hook for handle outside click - simple implementation from stack overflow
  useEffect(() => {
    function handleClickOutside(this: Document, event: MouseEvent) {
      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
        setIsOpened(false)
      }
    }

    document.addEventListener("mousedown", handleClickOutside);
    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
    };
  }, [containerRef]);

  return (<div ref={containerRef}>
    {opened ?
      <ApartmentCard
        image={props.image}
        address={props.address}
        area={props.area}
        rooms_number={props.rooms_number}
        floor={props.floor}
        floor_count={props.floor_count}
        rent={props.rent}
        handleClose={handleOnClose}
      /> :
      <ApartmentPoint
        price={props.rent}
        onClick={handleOnOpen}
      />}
  </div>)
}

export default MapPoint
Enter fullscreen mode Exit fullscreen mode

And all together

Let's modify Map component by adding apartments points inside overlay containers:

import { makeStyles } from "@material-ui/core";
import { useEffect, useRef, useState } from "react";
import Apartments from "./apartments";
import MapPoint from "./MapPoint";
import OverlayContainer from "./OverlayContainer";

type MapProps = {
  center: google.maps.LatLngLiteral
  zoom: number
}

const useStyles = makeStyles({
  map: {
    height: '100vh'
  }
})

function Map({ center, zoom }: MapProps) {
  const ref = useRef(null);
  const [map, setMap] = useState<google.maps.Map<Element> | null>(null)
  const classes = useStyles();

  useEffect(() => {
    if (ref.current) { 
      let createdMap = new window.google.maps.Map(
        ref.current,
        {
          center,
          zoom,
          disableDefaultUI: true,
          clickableIcons: false
        }
      );
      setMap(createdMap)
    }
  }, [center, zoom]);

  return <div ref={ref} id="map" className={classes.map}>
    {Apartments.map((apartment, index) => (
      <OverlayContainer
        map={map}
        position={{
          lat: apartment.lat,
          lng: apartment.lng
        }}
        key={index}
      >
        <MapPoint
          image={apartment.image}
          address={apartment.address}
          area={apartment.area}
          rooms_number={apartment.rooms_number}
          floor={apartment.floor}
          floor_count={apartment.floor_count}
          rent={apartment.rent}
        />
      </OverlayContainer>
    ))}
  </div>;
}

export default Map
Enter fullscreen mode Exit fullscreen mode

Apartments mock-data example(apartments.ts):

const Apartments = [
  {
    "id": 1,
    "image": "https://storage.yandexcloud.net/apartment-images/2.jpg",
    "area": 34.9,
    "kitchen_area": null,
    "address": "Novoalekseevskaya  4d4",
    "lat": 55.80562399999999,
    "lng": 37.641239,
    "rooms_number": 1,
    "bedrooms_number": 1,
    "restrooms_number": 1,
    "floor": 3,
    "floor_count": 14,
    "rent": 1500
  },
  {
    "id": 2,
    "image": "https://storage.yandexcloud.net/apartment-images/10_S939Rcf.jpg",
    "area": 47,
    "kitchen_area": null,
    "address": "Valovaya street 31",
    "lat": 55.66497999999999,
    "lng": 37.857464,
    "rooms_number": 1,
    "bedrooms_number": 1,
    "restrooms_number": 1,
    "floor": 6,
    "floor_count": 9,
    "rent": 2000
  },
  {
    "id": 3,
    "image": "https://storage.yandexcloud.net/apartment-images/07_uvV7gIk.jpg",
    "area": 40.9,
    "kitchen_area": null,
    "address": "academic Volgyn street 8A",
    "lat": 55.68271799999999,
    "lng": 37.544263,
    "rooms_number": 3,
    "bedrooms_number": 2,
    "restrooms_number": 1,
    "floor": 2,
    "floor_count": 5,
    "rent": 3000
  }
]

export default Apartments
Enter fullscreen mode Exit fullscreen mode

Result

And our application will be:
result

P.S.

This is just the first article, in it I tried only to show how I work with google maps and react, in further articles there will be more logic and styling to get as close as possible to airbnb

Top comments (1)

Collapse
flov profile image
Florian Vallen

Great! Well done! Thanks for the repo, that really helped me

🌚 Browsing with dark mode makes you a better developer.

It's a scientific fact.