1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React で Google Maps Platform を使ったホテル検索アプリを作る

1
Posted at

はじめに

この記事では、ホテル検索アプリを作りながら Google Maps Platform の構成と使い方を記載します。

開発環境

  • OS:Windows 11
  • エディタ:VSCode
  • Node.js:24.21.0
  • Vite:8.3.0
  • React:19.2.8
  • @vis.gl/react-google-maps:1.10.1

アプリ概要と利用する Google Maps Platform API

左に検索フォームと結果一覧、右に地図を置いた画面を作成します。「駅近」などのキーワードで検索すると、条件と一致するホテルにピンが立ちます。ピンか一覧をクリックすると、ホテル名と住所の吹き出しが開きます。

Google Maps Platform は、機能ごとに4つの製品群に分かれています。今回使うのは Maps と Places の2つです。

アプリの各機能と、呼び出す API の対応は次のとおりです。

機能 利用API Maps JavaScript API のクラス
地図の表示 Maps JavaScript API google.maps.Map
ピンの表示 Maps JavaScript API google.maps.marker.AdvancedMarkerElement
吹き出しの表示 Maps JavaScript API google.maps.InfoWindow
キーワード検索 Places API (New) google.maps.places.Place

React から Maps JavaScript API を使うために、@vis.gl/react-google-maps を使います。Google と vis.gl チームが共同で開発しているライブラリです。Maps JavaScript API のクラスを React コンポーネントとして書けます。

@vis.gl/react-google-maps Maps JavaScript API の対応要素
<APIProvider> スクリプトの読み込み(google.maps.importLibrary)
<Map> google.maps.Map
<AdvancedMarker> google.maps.marker.AdvancedMarkerElement
<Pin> google.maps.marker.PinElement
<InfoWindow> google.maps.InfoWindow
useMap() 生成済みの google.maps.Map インスタンスの取得
useMapsLibrary("places") google.maps.importLibrary("places")

手順1 Google Cloud 設定

Google Cloud Console で次の設定をします。

作業 設定画面 設定内容
プロジェクト作成 プロジェクトの選択 新規プロジェクトを作成し、請求先アカウントを紐付ける
API の有効化 API とサービス > ライブラリ Maps JavaScript API と Places API (New) を有効にする
API キーの作成 API とサービス > 認証情報 API キーを作成する
アプリケーションの制限 API キーの編集画面 ウェブサイト(HTTP リファラー)に http://localhost:5173/* を登録する
API の制限 API キーの編集画面 Maps JavaScript API と Places API (New) だけを許可する
Map ID の作成 Google Maps Platform > マップ管理 JavaScript 用のベクター地図の Map ID を作成する

Maps JavaScript API のキーは、ブラウザに配信する JavaScript に含まれます。開発者ツールを開けば誰でも読めます。そのため、キーを隠すのではなく、呼び出せる URL と API を制限して不正利用を防ぎます。

Map ID は、Cloud Console で管理する地図設定の ID です。AdvancedMarker を使うには Map ID が必須です。開発中は Map ID を作らずに、テスト用の DEMO_MAP_ID を指定しても動きます。

手順2 プロジェクト作成

Vite の React + TypeScript テンプレートでプロジェクトを作ります。

npm create vite@latest google-maps-sample -- --template react-ts
cd google-maps-sample
npm install
npm install "@vis.gl/react-google-maps"
npm install -D @types/google.maps

@types/google.maps は、google.maps 名前空間の型定義です。テンプレートの tsconfig.app.json は types で読み込む型定義を指定しているため、追加しただけでは読み込まれません。types に "google.maps" を追記します。

tsconfig.app.json
{
  "compilerOptions": {
    "types": ["vite/client", "google.maps"]
  }
}

API キーと Map ID は環境変数に置きます。

.env.local
VITE_GOOGLE_MAPS_API_KEY=取得したAPIキー
VITE_GOOGLE_MAPS_MAP_ID=DEMO_MAP_ID

環境変数の型を宣言します。

src/env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_GOOGLE_MAPS_API_KEY: string;
  readonly VITE_GOOGLE_MAPS_MAP_ID: string;
}

設定値を1つのファイルにまとめます。初期表示の中心は東京駅にしています。

src/config.ts
export const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
export const GOOGLE_MAPS_MAP_ID = import.meta.env.VITE_GOOGLE_MAPS_MAP_ID;

export const TOKYO_STATION = { lat: 35.681236, lng: 139.767125 };

テンプレートの CSS は地図の表示と合わないので、中身を置き換えます。

src/index.css
body {
  margin: 0;
  font-family: sans-serif;
}

手順3 地図表示

APIProvider で Maps JavaScript API を読み込み、その中に Map を置きます。

src/App.tsx
import { APIProvider, Map } from "@vis.gl/react-google-maps";
import { GOOGLE_MAPS_API_KEY, GOOGLE_MAPS_MAP_ID, TOKYO_STATION } from "./config";

export default function App() {
  return (
    <APIProvider apiKey={GOOGLE_MAPS_API_KEY} language="ja" region="JP">
      <Map
        style={{ width: "100vw", height: "100vh" }}
        defaultCenter={TOKYO_STATION}
        defaultZoom={15}
        mapId={GOOGLE_MAPS_MAP_ID}
        gestureHandling="greedy"
      />
    </APIProvider>
  );
}

APIProvider は Maps JavaScript API のスクリプトを1回だけ読み込みます。language と region は地図のラベルの言語と、地名表記の地域を指定します。

Map の中心座標とズームには、2種類の指定方法があります。

props 動作 用途
defaultCenter / defaultZoom 初回表示の値だけを渡す。ドラッグやズームは地図が自分で管理する 利用者が自由に地図を動かす画面
center / zoom React の state で値を管理する。変更時は onCameraChanged で state を更新する 表示位置をアプリ側で同期したい画面

今回は利用者が自由に動かす画面なので default 付きを使います。center だけを渡して state を更新しないと、地図をドラッグしても元の位置に戻ります。最初はこの挙動で少し戸惑いました。

gestureHandling="greedy" は、Ctrl キーを押さずにマウスホイールでズームする設定です。

npm run dev

http://localhost:5173 を開くと、東京駅を中心にした地図が表示されます。

image.png

手順4 マーカー・吹き出し表示

マーカーは AdvancedMarker を使います。google.maps.Marker は2024年2月21日に非推奨となり、AdvancedMarkerElement の使用が推奨されています。@vis.gl/react-google-maps にも古い Marker コンポーネントが残っていますが、新規開発では使いません。

マーカーと吹き出しを1つのコンポーネントにまとめます。

src/components/PlaceMarker.tsx
import {
  AdvancedMarker,
  InfoWindow,
  Pin,
  useAdvancedMarkerRef,
} from "@vis.gl/react-google-maps";

type Props = {
  position: { lat: number; lng: number };
  title: string;
  description?: string;
  selected: boolean;
  onSelect: () => void;
  onClose: () => void;
};

export function PlaceMarker({
  position,
  title,
  description,
  selected,
  onSelect,
  onClose,
}: Props) {
  // 吹き出しの表示位置を決めるため、マーカーのインスタンスを取得する
  const [markerRef, marker] = useAdvancedMarkerRef();

  return (
    <>
      <AdvancedMarker ref={markerRef} position={position} title={title} onClick={onSelect}>
        <Pin
          background={selected ? "#d93025" : "#1a73e8"}
          borderColor="#ffffff"
          glyphColor="#ffffff"
        />
      </AdvancedMarker>
      {selected && (
        <InfoWindow anchor={marker} onCloseClick={onClose}>
          <strong>{title}</strong>
          {description && <p>{description}</p>}
        </InfoWindow>
      )}
    </>
  );
}

InfoWindow の anchor にマーカーを渡すと、吹き出しがマーカーの真上に表示されます。Pin は標準のピン形状の色を変えるコンポーネントです。選択中のピンを赤、それ以外を青にしました。

動作確認のため、東京駅に1本だけマーカーを立てます。

src/App.tsx
import { useState } from "react";
import { APIProvider, Map } from "@vis.gl/react-google-maps";
import { PlaceMarker } from "./components/PlaceMarker";
import { GOOGLE_MAPS_API_KEY, GOOGLE_MAPS_MAP_ID, TOKYO_STATION } from "./config";

export default function App() {
  const [selected, setSelected] = useState(false);

  return (
    <APIProvider apiKey={GOOGLE_MAPS_API_KEY} language="ja" region="JP">
      <Map
        style={{ width: "100vw", height: "100vh" }}
        defaultCenter={TOKYO_STATION}
        defaultZoom={15}
        mapId={GOOGLE_MAPS_MAP_ID}
        gestureHandling="greedy"
      >
        <PlaceMarker
          position={TOKYO_STATION}
          title="東京駅"
          description="東京都千代田区丸の内1丁目"
          selected={selected}
          onSelect={() => setSelected(true)}
          onClose={() => setSelected(false)}
        />
      </Map>
    </APIProvider>
  );
}

ピンをクリックすると吹き出しが開き、×ボタンで閉じます。

image.png

手順5 ホテル検索(Places API (New))

場所の検索には google.maps.places.Place クラスを使います。古い PlacesService は、2025年3月1日以降、新規利用者には提供されていません。

用途 旧 API(新規利用不可) 新 API
テキスト検索・周辺検索 PlacesService Place.searchByText / Place.searchNearby
入力補完のデータ取得 AutocompleteService AutocompleteSuggestion
入力補完の UI Autocomplete PlaceAutocompleteElement

Place.searchByText では、取得するデータ項目を fields で必ず指定します。リクエストは、指定した項目の中で最も高い SKU の料金で課金されます。今回はホテル名・座標・住所の3項目だけを取得します。

検索処理をカスタムフックにまとめます。

src/hooks/useTextSearch.ts
import { useState } from "react";
import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps";

export type SearchResult = {
  id: string;
  name: string;
  address: string;
  position: { lat: number; lng: number };
};

export function useTextSearch() {
  const map = useMap();
  // places ライブラリの読み込みが終わるまでは null が返る
  const placesLib = useMapsLibrary("places");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [error, setError] = useState<string | null>(null);

  const search = async (query: string) => {
    if (!map || !placesLib || query.trim() === "") return;
    setError(null);

    try {
      const { places } = await placesLib.Place.searchByText({
        textQuery: query,
        // 検索対象をホテルに限定する
        includedType: "hotel",
        useStrictTypeFiltering: true,
        // 課金額はここで指定した項目で決まる
        fields: ["displayName", "location", "formattedAddress"],
        locationBias: map.getCenter(),
        language: "ja",
        region: "jp",
        maxResultCount: 10,
      });

      const next: SearchResult[] = places.flatMap((place) =>
        place.location
          ? [
              {
                id: place.id,
                name: place.displayName ?? "",
                address: place.formattedAddress ?? "",
                position: place.location.toJSON(),
              },
            ]
          : [],
      );
      setResults(next);

      // すべての検索結果が画面に入るように表示位置を調整する
      if (next.length > 0) {
        const bounds = new google.maps.LatLngBounds();
        next.forEach((result) => bounds.extend(result.position));
        map.fitBounds(bounds);
      }
    } catch (e) {
      setError(e instanceof Error ? e.message : "検索に失敗しました");
    }
  };

  return { results, error, search };
}

includedType に場所の種類を指定すると、その種類の場所を優先して返します。useStrictTypeFiltering を true にすると、指定した種類以外は結果から除外されます。今回は "hotel" を指定して、ホテルだけを検索対象にしました。

宿泊施設の種類は、Places API では次のように分かれています。

指定値 対象
hotel ホテル
japanese_inn 旅館
hostel ホステル
lodging 宿泊施設全般

旅館やホステルも検索対象に含める場合は、"lodging" を指定します。

locationBias に地図の中心座標を渡すと、中心に近い場所が優先して返ります。地図を別の場所に動かしてから検索すると、その周辺のホテルが返ります。範囲外の場所も結果に含まれます。範囲内に絞る場合は locationRestriction を使います。

useMap() は、Map コンポーネントの外で呼んでも地図インスタンスを取得できます。id を指定しない Map は default という ID で登録されるためです。この仕組みにより、検索フォームと地図を別の場所に置けます。

検索フォーム・結果一覧・地図をまとめたコンポーネントを作ります。

src/components/PlaceFinder.tsx
import { useState, type FormEvent } from "react";
import { Map } from "@vis.gl/react-google-maps";
import { PlaceMarker } from "./PlaceMarker";
import { useTextSearch } from "../hooks/useTextSearch";
import { GOOGLE_MAPS_MAP_ID, TOKYO_STATION } from "../config";

export function PlaceFinder() {
  const { results, error, search } = useTextSearch();
  const [query, setQuery] = useState("ホテル");
  const [selectedId, setSelectedId] = useState<string | null>(null);

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setSelectedId(null);
    void search(query);
  };

  return (
    <div style={{ display: "grid", gridTemplateColumns: "320px 1fr", height: "100vh" }}>
      <aside style={{ padding: 16, overflowY: "auto" }}>
        <form onSubmit={handleSubmit}>
          <input
            value={query}
            placeholder="例:駅近"
            onChange={(e) => setQuery(e.target.value)}
          />
          <button type="submit">検索</button>
        </form>
        {error && <p role="alert">{error}</p>}
        <ul>
          {results.map((result) => (
            <li key={result.id}>
              <button type="button" onClick={() => setSelectedId(result.id)}>
                {result.name}
              </button>
            </li>
          ))}
        </ul>
      </aside>

      <Map
        defaultCenter={TOKYO_STATION}
        defaultZoom={15}
        mapId={GOOGLE_MAPS_MAP_ID}
        gestureHandling="greedy"
      >
        {results.map((result) => (
          <PlaceMarker
            key={result.id}
            position={result.position}
            title={result.name}
            description={result.address}
            selected={result.id === selectedId}
            onSelect={() => setSelectedId(result.id)}
            onClose={() => setSelectedId(null)}
          />
        ))}
      </Map>
    </div>
  );
}

選択中のホテルの ID を PlaceFinder の state で持ちます。一覧のボタンとマーカーのどちらをクリックしても、同じ state が更新されます。

App.tsx は APIProvider の中に PlaceFinder を置くだけになります。

src/App.tsx
import { APIProvider } from "@vis.gl/react-google-maps";
import { PlaceFinder } from "./components/PlaceFinder";
import { GOOGLE_MAPS_API_KEY } from "./config";

export default function App() {
  return (
    <APIProvider apiKey={GOOGLE_MAPS_API_KEY} language="ja" region="JP">
      <PlaceFinder />
    </APIProvider>
  );
}
npm run dev

初期表示のまま「駅近」で検索すると、東京駅周辺で条件と合致するホテルにピンが立ちます。一覧のホテル名をクリックすると、該当するピンが赤くなり、吹き出しが開きます。

google-maps-sample-Google-Chrome-2026-09-24-20-37-41.gif

まとめ

この記事では、ホテル検索アプリの作成を通して Google Maps Platform の構成と使い方について記載しました。地図とマーカーは Maps JavaScript API、場所の検索は Places API (New) が担当し、React からは @vis.gl/react-google-maps のコンポーネントとフックで呼び出せます。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?