【第2回】パチ飯マップ — Google Maps + Places API でお店を探す仕組み

2026年6月17日

はじめに

前回は、React + TypeScript + Firebase + Google Maps API を使ったクローズドコミュニティ向けのグルメ共有マップアプリの概要・主要機能・技術スタックを紹介しました。

今回は、その中核となる「お店を探す仕組み」を詳しく解説します。地図上のお店をタップして投稿を始めるフローと、Places Autocomplete を使った店舗検索バーの実装、そして検索・タップした店舗がサービス対象エリア内かどうかを判定する処理を取り上げます。Google Maps + Places API を使ったアプリ開発を検討している方の参考になれば幸いです。

お店を探す2つの入口:地図タップと検索バー

このアプリでは、投稿したい店舗を見つける方法を2つ用意しています。どちらも最終的には同じ「店舗確認シート→投稿フロー」につながります。

地図上のお店をタップして投稿を始める

投稿モードに切り替えると、Googleマップ標準のPOI(店舗・施設アイコン)をそのままタップできるようになります。

  • 地図上のPOIをタップ → そのPOIのplaceIdを取得
  • Places API で店名・住所・座標・カテゴリ情報(types)を取得
  • 飲食店・カフェ・バーなどに該当する場合のみ、店舗確認シートを表示
  • 飲食店以外(コンビニ・駅・公共施設など)をタップした場合は何も起こらない

Googleマップが既に持っている膨大な店舗データをそのまま使えるため、自前で店舗データベースを用意する必要がない点が大きなメリットです。

検索バーでお店を探す(Places Autocomplete)

Googleマップモードの上部には店舗検索バーを表示しており、店名や住所の一部を入力するだけで候補が絞り込まれます。

  • 2文字以上入力すると、候補リストが最大5件表示される
  • 候補をタップすると地図がその店舗にパン・ズームして移動する
  • サービス対象エリアの外にある店舗を選んだ場合は「このお店はエリア外です」と表示される
Googleマップモードの店舗検索バー:入力した文字に応じて候補が一覧表示される

Google Maps + Places API で実現した3つの技術ポイント

1. 地図POIタップを「飲食店だけ」に絞り込む

課題:Googleマップ上のPOIは飲食店だけでなく、コンビニ・駅・公共施設なども含まれる。すべてのPOIタップを投稿対象にすると、ユーザーが意図しない投稿が増えてしまう。
解決策:Places API から取得したtypes(カテゴリ配列)を、あらかじめ定義した飲食店系カテゴリの集合と照合し、該当しないPOIは投稿フローに進ませない。

以下は、タップされたPOIが飲食店かどうかを判定するコードです。

// 飲食店として扱うカテゴリの集合
const FOOD_PLACE_TYPES = new Set([
  'restaurant', 'food', 'bar', 'cafe',
  'night_club', 'meal_delivery', 'meal_takeaway', 'bakery',
])

map.addListener('click', (e: google.maps.MapMouseEvent) => {
  const placeId = (e as google.maps.MapMouseEvent & { placeId?: string }).placeId
  if (!placeId) return
  e.stop() // Googleマップ標準の情報ウィンドウを抑制

  placesService.getDetails(
    { placeId, fields: ['place_id', 'name', 'formatted_address', 'geometry', 'types'] },
    (place, status) => {
      if (status !== google.maps.places.PlacesServiceStatus.OK || !place?.geometry?.location) return

      const isFoodPlace = (place.types ?? []).some((t) => FOOD_PLACE_TYPES.has(t))
      if (!isFoodPlace) return // 飲食店以外は投稿フローに進まない

      // placeId・店名・住所・座標を店舗確認シートへ渡す
    }
  )
})

e.stop()でGoogleマップ標準の情報ウィンドウ表示をキャンセルし、代わりに自前の確認シートを開く点もポイントです。

2. Places Autocomplete をサービス対象エリアにバイアスする

課題:店名で検索すると、サービス対象エリアから離れた同名・類似店舗も候補に出てきてしまい、ユーザーが選び間違えやすい。
解決策:AutocompleteServicebounds(緯度経度の矩形)を渡し、検索候補をサービス対象エリア周辺に重み付けする。

以下は、入力2文字以上・300msのデバウンスをかけたうえで、エリアにバイアスした候補を取得するコードです。

// サービス対象エリアの矩形(緯度経度)
const AREA_BOUNDS = { north: 35.60, south: 35.50, east: 139.80, west: 139.70 }

useEffect(() => {
  if (query.length < 2) {
    setSuggestions([])
    return
  }
  const timer = setTimeout(() => {
    autocompleteService.getPlacePredictions(
      {
        input: query,
        types: ['establishment'],
        language: 'ja',
        bounds: new google.maps.LatLngBounds(
          new google.maps.LatLng(AREA_BOUNDS.south, AREA_BOUNDS.west),
          new google.maps.LatLng(AREA_BOUNDS.north, AREA_BOUNDS.east)
        ),
      },
      (predictions, status) => {
        if (status !== google.maps.places.PlacesServiceStatus.OK || !predictions) {
          setSuggestions([])
          return
        }
        setSuggestions(
          predictions.slice(0, 5).map((p) => ({
            placeId: p.place_id!,
            name: p.structured_formatting.main_text,
            address: p.structured_formatting.secondary_text ?? '',
          }))
        )
      }
    )
  }, 300)
  return () => clearTimeout(timer)
}, [query])

デバウンスを入れることで、1文字入力するごとにAPIを呼び出すことを防ぎ、不要なリクエスト数を抑えています。

3. サービス対象エリア外の店舗を選んだら警告表示する

課題:Autocompleteのboundsはあくまで「重み付け」であり、エリア外の店舗が候補に出てくる可能性は残る。エリア外の店舗への投稿を許可してしまうと、コミュニティの目的(限定エリアの情報共有)から外れてしまう。
解決策:候補選択時に取得した緯度経度を、サービス対象エリアの矩形と比較するバウンディングボックス判定を行い、エリア外であれば投稿フローに進ませずエラーメッセージを表示する。

以下は、選択した店舗の座標がエリア内かどうかを判定するコードです。

function isInBounds(lat: number, lng: number): boolean {
  return (
    lat >= AREA_BOUNDS.south &&
    lat <= AREA_BOUNDS.north &&
    lng >= AREA_BOUNDS.west &&
    lng <= AREA_BOUNDS.east
  )
}

function handleSelect(placeId: string) {
  placesService.getDetails(
    { placeId, fields: ['geometry', 'name', 'formatted_address', 'types'] },
    (place, status) => {
      if (status !== google.maps.places.PlacesServiceStatus.OK || !place?.geometry?.location) return

      const lat = place.geometry.location.lat()
      const lng = place.geometry.location.lng()
      if (!isInBounds(lat, lng)) {
        showError('このお店はエリア外です')
        return
      }

      map.panTo(place.geometry.location)
      map.setZoom(17)
      // 検索結果シートを表示
    }
  )
}

複雑な行政区域のポリゴンを使わず、シンプルな矩形判定にとどめているのもポイントです。実装・検証の手間を抑えつつ、サービスの目的に対しては十分な精度が得られています。

まとめ:Places API + バウンディングボックスでエリア限定の地図体験を作る理由

今回の記事で紹介したポイントをまとめます。

  • 地図上のPOIタップと検索バーの2つの入口から、同じ「店舗確認→投稿」フローに合流させることでUIをシンプルに保てる
  • Places APIのtypesを飲食店系カテゴリと照合することで、コンビニや駅などの不要なPOIタップを自動的に除外できる
  • Autocompleteのboundsでサービス対象エリアに候補を重み付けし、デバウンスでAPI呼び出し数を抑える
  • 最終的な座標はバウンディングボックスで判定し、エリア外の店舗への投稿を防ぐ

次回は、Firebase × サブドメインでエリアを分割する設計を詳しく解説します。サブドメインからどのFirestoreデータベースに接続するかを自動判定する仕組みや、エリアごとに名前付きデータベースを分けて運用するメリット・注意点など、複数エリアにサービスを展開する際に参考になる設計を紹介します。