【Nuxt.js3】Google Places APIをぽんぽこ叩いてお店舗検索

# なんの記事なん？
年末年始に体調崩して序盤ひまだったので冬休みの自由研究的ノリです。
古着屋とか雑貨屋とかカフェが割と好きで巡る時に毎回Google Mapで検索バーに「古着 地名」とか「雑貨屋 地名」とかワード入れて調べてみたいなのすごいだるいなって感じで、場所選んだらそれらのジャンルごとに店舗を取得するもの欲しいなと。
ついでにcompositionAPIが使えるようになってからの書き方知らんしNuxt3でやってみようかなという感じ。
まぁ実際はインスタで調べる方が多いよな、おれもそう。

# とりあえず画面
![google-places-api-demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953393/f0ef6152-c862-dccf-9b40-3b817cba0528.gif)

都道府県と市区町村を選択したらタブでジャンルが分かれていて、そのジャンルの店舗一覧と詳細データを取得して写真押せばGoogle map飛べるから経路はお前が調べろって感じのもの。（まぁAPI叩けたらええだけやし。。）
API自体のデフォルトの取得データ数が20件でページネーション追加で最大60件ですが今回は20件でやってます。
デザインはVuetify3を使用していますが、デザインとか無理マンでUIの構成はゴミなので目をつぶってもらってそのまま瞼の裏とか見といてもらえると助かります。

# お借りしたもの
都道府県と市区町村のデータは、少しいじってはいますが下記の方の記事のものをお借りしました。

https://qiita.com/next1ka2u/items/1d7e63c37d9a205219b6

# これは事前によろしく
Google Cloud Consoleでプロジェクトを作成してAPIキーの発行と使用するAPIの有効化を行なってください。
APIの制限でGeocoding APIとMaps Javascript ApiとPlaces APIを選択します。
ウェブサイトの制限はどこのページで使用するかは決まっていなかったのでとりあえず「*」つけてます。
![スクリーンショット 2023-01-03 18.10.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2953393/6db67b74-bb0a-f028-aae7-3e98248c4e6a.png)

# 使用するライブラリ
### js-api-loader
vue3-google-mapsと迷いましたが、過去の記事などでバグがどうとかもちらほら見えてビビりなので（今はもう大丈夫かもしれん）js-api-loaderを使います。
下記のコマンドでインストールします。

```terminal:ターミナル
$ npm i @googlemaps/js-api-loader
$ npm i -D @types/google.maps
```
# 実装

## 店舗一覧
```vue: searchReslt/index.vue
<template>
  <div>
    <Tabvar :id="id" :queryCityName="query_city_name" />
    <v-container>
      <v-row>
        <v-col cols="3" v-for="shop in search_result">
          <v-card
            v-ripple
            elevation="12"
            style="
              height: 450px;
              width: 100%;
              display: flex;
              flex-direction: column;
            "
          >
            <NuxtLink
              :to="`/municipalities/${id}/searchResult/detailPage?city_name=${query_city_name}&place_id=${shop.place_id}`"
              style="text-decoration: none"
            >
              <img
                v-if="shop.photos"
                :src="shop.photos[0].getUrl()"
                aspect-ratio="5"
                style="height: 160px; width: 100%; object-fit: cover"
              />
              <img
                v-else
                src="@/assets/img/noimage.png"
                aspect-ratio="5"
                style="height: 160px; width: 100%; object-fit: cover"
              />
              <v-card-title style="font-weight: bold; color: #ffff">{{
                shop.name
              }}</v-card-title>
              <div style="display: flex">
                <v-rating
                  :model-value="shop.rating"
                  class="ml-4"
                  color="amber"
                  density="compact"
                  half-increments
                  readonly
                  size="small"
                ></v-rating>
                <span style="color: #ffff" class="ml-2">{{ shop.rating }}</span>
              </div>
              <!--open_nowは非推奨でPlace DetailのisOpen()を使用するべきであるが
              このために何回もAPIを叩いてデータ回すのもめんどくさいのでこれで対応 -->
              <div v-if="shop.opening_hours">
                <v-card-text
                  style="color: red"
                  v-if="shop.opening_hours.open_now == false"
                  >● 営業時間外</v-card-text
                >
                <v-card-text
                  style="color: green"
                  v-else-if="shop.opening_hours.open_now == true"
                  >● 営業中</v-card-text
                >
              </div>
              <div v-else>
                <v-card-text style="color: gray"
                  >● 営業ステータス不明</v-card-text
                >
              </div>

              <v-card-text style="font-weight: bold; color: #ffff">{{
                shop.formatted_address
              }}</v-card-text>
            </NuxtLink>
            <v-card-actions style="margin-top: auto">
              <v-btn value="favorites">
                <v-icon class="mr-1">mdi-heart</v-icon>

                お気に入り
              </v-btn>
            </v-card-actions>
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </div>
</template>

<script>
import color from "@/assets/json/color.json";
import city from "@/assets/json/pref_city.json";
//Loaderインポートしてね
import { Loader } from "@googlemaps/js-api-loader";
//Mountedとかrefとかインポートいるってよ
import { onMounted, ref } from "vue";
import Tabvar from "/components/Tabvar";
export default {
  setup() {
    const route = useRoute();
    const query_city_name = route.query.city_name;
    const id = parseInt(route.params.idmunicipalitiesId);
    //setup構文ではリアクティブにするものをref,reactiveで定義する必要
    const search_result = ref([]);
    //参考にした記事ではLoaderのインスタンス化はmounted内で行う必要とあったが、mountedの外でも問題なかった
    const loader = new Loader({
      apiKey: "xxxxxxxxxxxxxxxxxxxxxxx",
      //とりあえず最新指定
      version: "weekly",
      //ライブラリの読み込み
      libraries: ["drawing", "geometry", "places", "visualization"],
    });

    onMounted(() => {
      loader
        .load()
        .then((google) => {
          //緯度と経度を取得するためにジオコードメソッドを使用する
          const geocoder = new google.maps.Geocoder();
          let lat = "";
          let lng = "";
          geocoder.geocode(
            {
              //クエリパラメータにある指定都市の名称をリクエスト
              address: query_city_name,
            },
            (result, status) => {
              //レスポンスとステータスを引数に取れる
              if (status == google.maps.places.PlacesServiceStatus.OK) {
                //周辺検索用に緯度と経度を取得する
                lat = result[0].geometry.location.lat();
                lng = result[0].geometry.location.lng();
                //ネットの記事では多くが下記の書き方だがオブジェクトでしか緯度と経度は取得できなかった
                // latLng = result[0].geometry.location
                // 下記の書き方だとlatLngオブジェクトが返ってきてテキスト検索のlocationにlatLngを渡した時に数字を入れろと怒られる
                // latLng = new google.maps.LatLng(lat, lng)
              }
            }
          );
          //float型にする必要
          const lat_lng = new google.maps.LatLng(
            parseFloat(lat),
            parseFloat(lng)
          );
          //Mapを表示したい場合は引数に要素を指定。今回は非表示なのでdiv要素を作成するだけ。
          const map = new google.maps.Map(document.createElement("div"));
          const service = new google.maps.places.PlacesService(map);
          //テキスト検索用のリクエスト
          service.textSearch(
            {
              //緯度経度
              location: lat_lng,
              //検索する半径（適当な2km）
              radius: 2000,
              //検索ワード
              query: "古着屋" + " " + query_city_name,
              //英語でレスポンスが返ってきてもバカでわからんので日本語指定
              language: "ja",
            },
            //第二引数で処理
            (result, status) => {
              //レスポンスとステータスを引数に取れる
              if (status == google.maps.places.PlacesServiceStatus.OK) {
                result.forEach((element) => {
                  //住所の最初が「日本、」で始まり、郵便番号も不要なため切り取り
                  //replace()だとundefinedに置き換わる。
                  let format = element.formatted_address.slice(12);
                  element.formatted_address = format;
                });
                //ref()を使用しているものに代入するときは.valueを使用する
                //これマジで忘れる。だるい。
                search_result.value = result;
              }
            }
          );
        })
        .catch((error) => {
          console.log(error);
        });
    });
    //templateで使用できるように返す
    return { search_result, id, query_city_name };
  },
};
</script>
```

## 店舗詳細
一覧とほぼ同じ
```vue:searchResult/detailPage/index.vue
<template>
  <div>
    <v-carousel
      cycle
      height="400"
      hide-delimiter-background
      show-arrows="hover"
    >
      <!-- 公式ではphoto_referenceを取得してPlace Photosにリクエスト送って写真の取得とされているが、
    photo_referenceが存在しない上にPlace Detailのレスポンスで全ての写真は取得できる -->
      <v-carousel-item v-for="(photo, i) in place_photos" :key="i">
        <!-- getUrl()で写真のURL取得 -->
        <a :href="place_google_map_url" target="_blank"
          ><img
            :src="photo.getUrl()"
            style="width: 100%; height: 100%; object-fit: cover"
        /></a>
      </v-carousel-item>
    </v-carousel>

    <v-table>
      <thead>
        <tr>
          <th class="text-center">項目名</th>
          <th class="text-center">店舗情報</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td class="text-center">店名</td>
          <td class="text-center">{{ place_name }}</td>
        </tr>
        <tr>
          <td class="text-center">評価</td>
          <td class="text-center">
            <v-rating
              :model-value="place_rating"
              color="amber"
              density="compact"
              half-increments
              readonly
              size="small"
            ></v-rating>
            <span style="color: #ffff" class="ml-2">{{ place_rating }}</span>
          </td>
        </tr>
        <tr>
          <td class="text-center">住所</td>
          <td class="text-center">{{ place_vicinity }}</td>
        </tr>
        <tr>
          <td class="text-center">営業ステータス</td>
          <td v-if="place_now == true" class="text-center" style="color: green">
            ● 営業中
          </td>
          <td
            v-else-if="place_now == false"
            class="text-center"
            style="color: red"
          >
            ● 営業時間外
          </td>
        </tr>
        <tr>
          <td class="text-center">営業時間</td>
          <td class="text-center">
            <div class="mt-2" v-for="place_week_day in place_week_days">
              {{ place_week_day }}
            </div>
          </td>
        </tr>
        <tr>
          <td class="text-center">Webサイト</td>
          <td class="text-center">
            <a :href="place_site" target="_blank">{{ place_site }}</a>
          </td>
        </tr>
      </tbody>
    </v-table>
  </div>
</template>

<script>
import { Loader } from "@googlemaps/js-api-loader";
import { onMounted, ref } from "vue";
export default {
  setup() {
    const route = useRoute();
    //一覧ページから遷移時に渡されたplace_id（データが持つ一意のid）を取得
    const query_place_id = route.query.place_id;
    const place_name = ref("");
    const place_photos = ref([]);
    const place_vicinity = ref("");
    const place_now = ref("");
    const place_week_days = ref([]);
    const place_site = ref("");
    const place_rating = ref(0);
    const place_google_map_url = ref("");

    const loader = new Loader({
      apiKey: "xxxxxxxxxxxxxxxxxxxxxxx",
      version: "weekly",
      libraries: ["drawing", "geometry", "places", "visualization"],
    });

    onMounted(() => {
      loader
        .load()
        .then((google) => {
          const map = new google.maps.Map(document.createElement("div"));
          const service = new google.maps.places.PlacesService(map);
          //getDetailsでplace_idを指定することでデータの詳細を取得
          service.getDetails(
            {
              //クエリパラメーターにあるplace_idで一意のデータの詳細をリクエストする
              placeId: query_place_id,
            },
            (place, status) => {
              if (status == google.maps.places.PlacesServiceStatus.OK) {
                place_name.value = place.name;
                place_photos.value = place.photos;
                place_vicinity.value = place.vicinity;
                place_now.value = place.opening_hours.isOpen();
                place_week_days.value = place.current_opening_hours.weekday_text;
                place_site.value = place.website;
                place_rating.value = place.rating;
                place_google_map_url.value = place.url;
              }
            }
          );
        })
        .catch((error) => {
          console.log(error);
        });
    });

    return {
      query_place_id,
      place_name,
      place_photos,
      place_vicinity,
      place_now,
      place_week_days,
      place_site,
      place_rating,
      place_google_map_url,
    };
  },
};
</script>
```
# さいごに
色んなデータ取れたり、地図も表示させようと思えば簡単そうでかなり楽しいです。あと公式ドキュメントがわかりやすい（あれ？違くねみたいなのもあったけど。。）
ただReact Hooksと同じようなものも使えるようになったので、フック使ったりしてもうちょいNuxt3自体のキャッチアップも必要やったかな。
teleportなんかもモーダルの作成が楽そうやしseverディレクトリを作成してAPI化もできたりと楽しそう。
あとref()に関してとりあえずつけといて損ないしつけとけ精神やけど、可読性的にやっぱりあかんのかな？

# 参考文献
https://qiita.com/next1ka2u/items/1d7e63c37d9a205219b6

https://developers.google.com/maps/documentation/places/web-service/overview

https://zenn.dev/yuta_enginner/articles/4e6c74d37de21a