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

Discussion (0)