ReactでGoogleMap使ってお店検索やってみた

# はじめに
タイトルの通りReactでGoogleMapを使用し、検索された場所の付近のお店を検索して表示するというのをやってみました。
ちなみにコードは拙いところも多々あるので、あくまで自分の備忘録として残させていただきます。
よかったらみていただければ幸いです。
この記事では[react-google-maps/api](https://www.npmjs.com/package/@react-google-maps/api)を使用して実装していきます。

# 今回実装する機能について
- Googleマップを使った地図の表示
- 検索されたキーワードを使って、動的に地図の位置を変更
- キーワード付近にある飲食店（今回は居酒屋に特化）を複数件を表示
- 簡単なお店の情報をモーダルで表示

# 使用したパッケージやライブラリ
- react: 18.0.0
- TypeScript: 4.7.3
- mui/icons-material: 5.11.11
- mui/material: 5.11.15
- react-google-maps/api: 2.18.1

※MaterialUiはデザインの簡単な調整に使っただけなので、今回の内容とは直接的ではないですが、一応記載

# 事前準備
- GoogleMapのAPIキーを取得

https://developers.google.com/maps/documentation/javascript/get-api-key?hl=ja

# 実装したコードについて
componentのみですが、まるっと記載します。
```typescript
import { useCallback, useRef, useState } from 'react';

// mui
import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { CircularProgress, TextField } from '@mui/material';;

// GoogleMap
import {
  GoogleMap,
  Marker,
  useLoadScript,
} from "@react-google-maps/api";

const theme = createTheme();
const containerStyle = {
  height: "70vh",
  width: "100%",
};

// Google側の設定引用
type Libraries = ("drawing" | "geometry" | "localContext" | "places" | "visualization")[];
const libraries: Libraries = ["places"]; // PlacesAPIを使用します。

const options = {
  disableDefaultUI: true,
  zoomControl: true,
};

type MarkerPoint = {
  lat: number,
  lng: number,
}
const center: MarkerPoint = { // なんとなくの六本木です
  lat: 35.66581861,
  lng: 139.72951166,
};

let Map: google.maps.Map;
let infoWindows: Array<google.maps.InfoWindow | undefined | null> = [];

// GoogleMapコンポーネント
export default function SearchMap() {
  const [searchWord, setSearchWord] = useState<string>('');
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [markerPoint, setMarkerPoint] = useState<MarkerPoint>(center);

  const { isLoaded, loadError } = useLoadScript({
    googleMapsApiKey: '-------------------', // ご利用環境のAPIキーを設定
    libraries,
  });
  const mapRef = useRef();
  const onMapLoad = useCallback((map: any) => {
    mapRef.current = map;
  }, []);

  // Map描画まで画面を見せない
  if (loadError) return "Error";
  if (!isLoaded) return "Loading...";

  /**
   * 入力されたワードでMap検索開始
   *
   */
  function getMapData() {
    try {
      setIsLoading(true);
      const geocoder = new window.google.maps.Geocoder();
      let getLat = 0;
      let getLng = 0;
      geocoder.geocode({ address: searchWord }, async (results, status) => {
        if (status === 'OK' && results) {
          getLat = results[0].geometry.location.lat();
          getLng = results[0].geometry.location.lng();
          const center = {
            lat: results[0].geometry.location.lat(),
            lng: results[0].geometry.location.lng()
          };
          setMarkerPoint(center);
          getNearFood(getLat, getLng);
        }
      });

      setIsLoading(false);
    } catch (error) {
      alert('検索処理でエラーが発生しました！');
      setIsLoading(false);
      throw error;
    }
  }

  /**
   * 近場のご飯屋さんを検索して表示
   *
   */
  function getNearFood(lat: Number, lng: Number) {
    try {
      if (document.getElementById('map') == null || typeof document.getElementById('map') == null) {
        return;
      }
      var pyrmont = new google.maps.LatLng(
        parseFloat(lat.toString()),
        parseFloat(lng.toString()),
      );
      Map = new google.maps.Map(document.getElementById('map')!, {
        center: pyrmont,
        zoom: 17
      });

      var request = {
        location: pyrmont,
        radius: 500,
        type: "restaurant",
        keyword: '居酒屋', // 検索地点の付近を`keyword`を使って検索する
      };
      var service = new google.maps.places.PlacesService(Map);
      service.nearbySearch(request, callback);
    } catch (error) {
      alert('検索処理でエラーが発生しました！');
      throw error;
    }
  }

  /**
   * `nearbySearch`のコールバック処理
   *
   */
  function callback(result: any, status: any) {
    if (status == google.maps.places.PlacesServiceStatus.OK) {
      for (var i = 0; i < result.length; i++) {
        createMarker(result[i]);
      }
    }
    return;
  }

  /**
   * 検索結果の箇所にマーカーを設定する
   *
   */
  function createMarker(place: google.maps.places.PlaceResult) {
    if (!place.geometry || !place.geometry.location) return;
    // お店情報マーカー
    const marker = new google.maps.Marker({
      map: Map,
      position: place.geometry.location,
      title: place.name,
      label: place.name?.substr(0, 1),
      optimized: false,
    });

    // お店情報ウィンドウ
    infoWindows[0] = new google.maps.InfoWindow();

    // ウィンドウにて表示する情報
    const price = (place.price_level) ? place.price_level : '取得できませんでした';

    const infoList = [
      place.name,
      `ランク：${place.rating}`,
      `金額：${price}`,
      (place.photos && place.photos.length > 0) ?
        `<p><img style="max-width:200px" src="${place.photos[0].getUrl()}"/></p>` : null
    ];

    const info = infoList.join('<br>'); // 改行区切りで加工して見せる
    google.maps.event.addListener(marker, "click", () => {
      if (infoWindows[1]) infoWindows[1].close(); // マーカーの詳細表示を複数表示をさせない
      if (infoWindows[0] == undefined || infoWindows[0] == null) return;
      infoWindows[0].close();
      infoWindows[0].setContent(info);
      infoWindows[0].open(marker.getMap(), marker);
    });
  }

  return (
    <ThemeProvider theme={theme}>
      <Container component="main">
        <CssBaseline />
        <Box>
          <GoogleMap
            id="map"
            mapContainerStyle={containerStyle}
            zoom={17}
            center={markerPoint}
            options={options}
            onLoad={onMapLoad}
          >
            <Marker position={markerPoint} />
          </GoogleMap>
        </Box>
        <Box component="form" sx={{ mt: 2 }}>
          {
            isLoading === true ? <div style={{
              marginTop: 10, textAlign
                : 'center'
            }}>
              <CircularProgress />
            </div> : null
          }
          <TextField id="standard-basic" label="検索ワード" variant="standard"
            value={searchWord}
            style={{ width: '100%' }}
            onChange={(e) => { setSearchWord(e.target.value) }} />
          <Button
            type="button"
            onClick={async () => { await getStaionInfo() }}
            fullWidth
            variant="contained"
            sx={{ mt: 3, mb: 2 }}
          >
            検索
          </Button>
        </Box>
      </Container>
    </ThemeProvider>
  );
}
```

# 各機能とコードの説明
## Googleマップを使った地図の表示
コード上では以下の箇所で実施しています。
```typescript
type MarkerPoint = {
  lat: number,
  lng: number,
}
const center: MarkerPoint = { // なんとなくの六本木です
  lat: 35.66581861,
  lng: 139.72951166,
};
```
上記箇所で読み込み時の緯度経度を設定しています。
```typescript
export default function SearchMap() {
  const [searchWord, setSearchWord] = useState<string>('');
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [markerPoint, setMarkerPoint] = useState<MarkerPoint>(center); // 初回のマーカーを設定
...
...
    <ThemeProvider theme={theme}>
      <Container component="main">
        <CssBaseline />
        <Box>
          <GoogleMap
            id="map"
            mapContainerStyle={containerStyle}
            zoom={17}
            center={markerPoint}
            options={options}
            onLoad={onMapLoad}
          >{/* optionやcenterを設定してやGoogleMapを画面に組み込む */}
            <Marker position={markerPoint} />
            {/* マーカーも設定 */}
          </GoogleMap>
        </Box>
...
```
実際の画面では以下のように表示ができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486966/4da15b0c-07d8-eeb3-e697-95f71b78b256.png)
狙い通り六本木が表示できてますね。※六本木である必要は特にありません。


## 検索されたキーワードを使って、動的に地図の位置を変更
前提として検索キーワードは以下の箇所で値を保持させています。
```typescript
          <TextField id="standard-basic" label="検索ワード" variant="standard"
            value={searchWord}
            style={{ width: '100%' }}
            onChange={(e) => { setSearchWord(e.target.value) }} />
```
検索処理は以下のボタンで発火します。
```typescript
          <Button
            type="button"
            onClick={async () => { await getMapData() }}
            fullWidth
            variant="contained"
            sx={{ mt: 3, mb: 2 }}
          >
            検索
          </Button>
```
検索処理は以下の通りです。
```typescript
  /**
   * 入力されたワードでMap検索開始
   *
   */
  function getMapData() {
    try {
      setIsLoading(true);
      // geocoderオブジェクトの取得
      const geocoder = new window.google.maps.Geocoder();
      let getLat = 0;
      let getLng = 0;
      // 検索キーワードを使ってGeocodeでの位置検索
      geocoder.geocode({ address: searchWord }, async (results, status) => {
        if (status === 'OK' && results) {
          getLat = results[0].geometry.location.lat();
          getLng = results[0].geometry.location.lng();
          const center = {
            lat: results[0].geometry.location.lat(),
            lng: results[0].geometry.location.lng()
          };
          setMarkerPoint(center); // ここで検索対象の緯度軽度にマーカーの位置を変更
          getNearFood(getLat, getLng);
        }
      });

      setIsLoading(false);
    } catch (error) {
      alert('検索処理でエラーが発生しました！');
      setIsLoading(false);
      throw error;
    }
  }
```
以下は「渋谷駅」での検索結果です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486966/ab48a6ff-2d49-a3e9-c39a-db605c9651f6.png)
渋谷駅が中央でマークされていることがわかります。

## キーワード付近にある飲食店（今回は居酒屋に特化）を複数件を表示
まず、以下の処理を実行して`PlacesAPI`の`nearbySearch`をリクエストしています。
```typescript
  /**
   * 近場のご飯屋さんを検索して表示
   *
   */
  function getNearFood(lat: Number, lng: Number) {
    if (document.getElementById('map') == null || typeof document.getElementById('map') == null) {
      return;
    } // マップがなかったら処理させない

    try {
      let pyrmont = new google.maps.LatLng(
        parseFloat(lat.toString()),
        parseFloat(lng.toString()),
      );
      // キーワード検索で取得できた位置の緯度軽度を使用してMapを再生成
      Map = new google.maps.Map(document.getElementById('map')!, {
        center: pyrmont,
        zoom: 17
      });

      let request = {
        location: pyrmont,
        radius: 500,
        type: "restaurant",
        keyword: '居酒屋', // 検索地点の付近を`keyword`を使って検索する
      };
      // Mapオブジェクトを使用してPlacesAPIを生成
      let service = new google.maps.places.PlacesService(Map);
      // PlacesAPIのnearbySearch使って再度リクエスト
      service.nearbySearch(request, callback);
    } catch (error) {
      alert('検索処理でエラーが発生しました！');
      throw error;
    }
  }
```
キーワード付近の結果は先ほど記載の内容と変わらないかつ、次の内容で画像を載せるのでここでは割愛します。

## 簡単なお店の情報をモーダルで表示
以下の処理がコールバック処理になります。（名前まんまですが、、）
```typescript
  /**
   * `nearbySearch`のコールバック処理
   *
   */
  function callback(result: any, status: any) {
    if (status == google.maps.places.PlacesServiceStatus.OK) {
      for (var i = 0; i < result.length; i++) {
        // コールバックで受け取ったデータを使って各お店のマーカーを表示する
        createMarker(result[i]);
      }
    }
    return;
  }
```
マーカー表示処理は以下の通りです。
```typescript
  /**
   * 検索結果の箇所にマーカーを設定する
   *
   */
  function createMarker(place: google.maps.places.PlaceResult) {
    if (!place.geometry || !place.geometry.location) return;
    // お店情報マーカー
    const marker = new google.maps.Marker({
      map: Map,
      position: place.geometry.location,
      title: place.name,
      label: place.name?.substr(0, 1),
      optimized: false,
    });

    // お店情報ウィンドウ
    infoWindows[0] = new google.maps.InfoWindow();

    // ウィンドウにて表示する情報
    const price = (place.price_level) ? place.price_level : '取得できませんでした';

    const infoList = [
      place.name,
      `ランク：${place.rating}`,
      `金額：${price}`,
      (place.photos && place.photos.length > 0) ?
        `<p><img style="max-width:200px" src="${place.photos[0].getUrl()}"/></p>` : null
    ];

    const info = infoList.join('<br>'); // 改行区切りで加工して見せる

    // マーカーにクリックイベントを付与
    google.maps.event.addListener(marker, "click", () => {
      // すでに他のマーカーがオープンになっている場合はそのマーカーを閉じる
      if (infoWindows[1]) infoWindows[1].close();
      if (infoWindows[0] == undefined || infoWindows[0] == null) return;
      infoWindows[0].close();
      infoWindows[0].setContent(info);
      infoWindows[0].open(marker.getMap(), marker);
    });
  }
```

マーカーをクリックすると簡易ですが、お店の情報が出せます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486966/ad63e06c-6c85-71a5-e733-6f473b0ff2ee.png)
美味しそうですね。
今回は以下のレスポンス項目のみを使用しましたが、他にもいろいろあるので使ってみたいですね。
- name
- rating
- price_level
- photos
[PlaceAPIのレスポンス](https://developers.google.com/maps/documentation/places/web-service/search-nearby?hl=ja#Place)

# 最後に
初めて使ってみましたが、割と簡単に便利に使えるのでよかったです！
GoogleMapのAPIは他にも機能があるので、組み合わせて使っていろいろやってみたいなと思っています。
ぜひどなたかの参考になれば幸いです！
お読みいただきありがとうございました。


# 参考サイト

https://developers.google.com/maps/documentation/javascript/react-map?hl=ja

https://developers.google.com/maps/documentation/javascript/places?hl=ja

https://react-google-maps-api-docs.netlify.app/