1. 始める前に
この Codelab では、Google Maps Platform Places UI キットを使用して、完全にインタラクティブなローカル検索アプリケーションを構築する方法を学びます。
前提条件
- 必要な API と認証情報が構成されている Google Cloud プロジェクト。
- HTML と CSS に関する基本的な知識
- 最新の JavaScript の知識。
- 最新のウェブブラウザ(Chrome の最新バージョンなど)。
- 任意のテキスト エディタ。
演習内容
- JavaScript クラスを使用してマッピング アプリケーションを構造化します。
- Web Components を使用して地図を表示する
- Place Search Element を使用して、テキスト検索を実行し、その結果を表示します。
- カスタムの
AdvancedMarkerElement
地図マーカーをプログラムで作成、管理します。 - ユーザーが場所を選択したときに Place Details 要素を表示します。
- Geocoding API を使用して、動的で使いやすいインターフェースを作成します。
必要なもの
- 課金を有効にした Google Cloud プロジェクト
- Google Maps Platform API キー
- マップ ID
- 次の API が有効になっていること:
- Maps JavaScript API
- Places UI キット
- Geocoding API
2. セットアップする
次の有効化の手順では、Maps JavaScript API、Places UI Kit、Geocoding API を有効にする必要があります。
Google Maps Platform を設定する
課金を有効にした Google Cloud Platform アカウントとプロジェクトをまだ作成していない場合は、Google Maps Platform スタートガイドに沿って請求先アカウントとプロジェクトを作成してください。
- Cloud Console で、プロジェクトのプルダウン メニューをクリックし、この Codelab に使用するプロジェクトを選択します。
3. アプリケーション シェルと機能マップ
最初の手順では、アプリケーションの完全なビジュアル レイアウトを作成し、JavaScript のクリーンなクラスベースの構造を確立します。これにより、強固な基盤を構築できます。このセクションの終わりには、インタラクティブな地図を表示するスタイル付きのページが完成します。
HTML ファイルを作成する
まず、index.html
という名前のファイルを作成します。このファイルには、ヘッダー、検索フィルタ、サイドバー、地図コンテナ、必要なウェブ コンポーネントなど、アプリケーションの完全な構造が含まれます。
次のコードを index.html
にコピーします。YOUR_API_KEY_HERE
はご自身の Google Maps Platform API キーに、DEMO_MAP_ID
はご自身の Google Maps Platform マップ ID に置き換えてください。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Local Search App</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Google Fonts: Roboto -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<!-- GMP Bootstrap Loader -->
<script>
(g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");for(k in g)e.set(k.replace(/[A-Z]/g,t=>"_"+t[0].toLowerCase()),g[k]);e.set("callback",c+".maps."+q);a.src=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({
key: "YOUR_API_KEY_HERE",
v: "weekly",
libraries: "places,maps,marker,geocoding"
});
</script>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<!-- Header for search controls -->
<header class="top-header">
<div class="logo">
<svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" fill="currentColor"></path></svg>
<span>PlaceFinder</span>
</div>
<div class="search-container">
<input
type="text"
id="query-input"
placeholder="e.g., burger in New York"
value="burger"
/>
<button id="search-button" aria-label="Search">Search</button>
</div>
<div class="filter-container">
<label class="open-now-label">
<input type="checkbox" id="open-now-filter"> Open Now
</label>
<select id="rating-filter" aria-label="Minimum rating">
<option value="0" selected>Any rating</option>
<option value="1">1+ ★</option>
<option value="2">2+ ★★</option>
<option value="3">3+ ★★★</option>
<option value="4">4+ ★★★★</option>
<option value="5">5 ★★★★★</option>
</select>
<select id="price-filter" aria-label="Price level">
<option value="0" selected>Any Price</option>
<option value="1">$</option>
<option value="2">$$</option>
<option value="3">$$$</option>
<option value="4">$$$$</option>
</select>
</div>
</header>
<!-- Main content area -->
<div class="app-container">
<!-- Left Panel: Results -->
<div class="sidebar">
<div class="results-header">
<h2 id="results-header-text">Results</h2>
</div>
<div class="results-container">
<gmp-place-search id="place-search-list" class="hidden" selectable>
<gmp-place-all-content></gmp-place-all-content>
<gmp-place-text-search-request></gmp-place-text-search-request>
</gmp-place-search>
<div id="placeholder-message" class="placeholder">
<p>Your search results will appear here.</p>
</div>
<div id="loading-spinner" class="spinner-overlay">
<div class="spinner"></div>
</div>
</div>
</div>
<!-- Right Panel: Map -->
<div class="map-container">
<gmp-map
center="40.758896,-73.985130"
zoom="13"
map-id="DEMO_MAP_ID"
>
</gmp-map>
<div id="details-container">
<gmp-place-details-compact>
<gmp-place-details-place-request></gmp-place-details-place-request>
<gmp-place-all-content></gmp-place-all-content>
</gmp-place-details-compact>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
CSS ファイルを作成する
次に、style.css
という名前のファイルを作成します。最初からすっきりとしたモダンな外観にするために、必要なスタイルをすべて追加します。この CSS は、全体的なレイアウト、色、フォント、すべての UI 要素の外観を処理します。
次のコードを style.css
にコピーします。
/* style.css */
:root {
--primary-color: #1a73e8;
--text-color: #202124;
--text-color-light: #5f6368;
--background-color: #f8f9fa;
--panel-background: #ffffff;
--border-color: #dadce0;
--shadow-color: rgba(0, 0, 0, 0.1);
}
body {
font-family: 'Roboto', sans-serif;
margin: 0;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
background-color: var(--background-color);
color: var(--text-color);
}
.hidden {
display: none !important;
}
.top-header {
display: flex;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
background-color: var(--panel-background);
gap: 24px;
flex-shrink: 0;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
font-size: 22px;
font-weight: 700;
color: var(--primary-color);
}
.search-container {
display: flex;
flex-grow: 1;
max-width: 720px;
}
.search-container input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 8px 0 0 8px;
font-size: 16px;
transition: box-shadow 0.2s ease;
}
.search-container input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}
.search-container button {
padding: 0 20px;
border: 1px solid var(--primary-color);
border-radius: 0 8px 8px 0;
background-color: var(--primary-color);
color: white;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: background-color 0.2s ease;
}
.search-container button:hover {
background-color: #185abc;
}
.filter-container {
display: flex;
gap: 12px;
align-items: center;
}
.filter-container select, .open-now-label {
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--panel-background);
font-size: 14px;
cursor: pointer;
transition: border-color 0.2s ease;
}
.filter-container select:hover, .open-now-label:hover {
border-color: #c0c2c5;
}
.open-now-label {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.app-container {
display: flex;
flex-grow: 1;
overflow: hidden;
}
.sidebar {
width: 35%;
min-width: 380px;
max-width: 480px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
background-color: var(--panel-background);
overflow: hidden;
}
.results-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.results-header h2 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.results-container {
flex-grow: 1;
position: relative;
overflow-y: auto;
overflow-x: hidden;
}
.placeholder {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
padding: 2rem;
box-sizing: border-box;
}
.placeholder p {
color: var(--text-color-light);
font-size: 1.1rem;
}
gmp-place-search {
width: 100%;
}
.map-container {
flex-grow: 1;
position: relative;
}
gmp-map {
width: 100%;
height: 100%;
}
.spinner-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
}
.spinner-overlay.visible {
opacity: 1;
visibility: visible;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e0e0e0;
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
gmp-place-details-compact {
width: 350px;
display: none;
border: none;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
gmp-place-details-compact::after {
content: '';
position: absolute;
bottom: -12px;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 12px;
background-color: var(--panel-background);
clip-path: polygon(50% 100%, 0 0, 100% 0);
}
JavaScript アプリケーション クラスを作成する
最後に、script.js
という名前のファイルを作成します。アプリケーションは PlaceFinderApp
という JavaScript クラス内に構築します。これにより、コードが整理され、状態が適切に管理されます。
この初期コードでは、クラスを定義し、constructor
内のすべての HTML 要素を見つけ、Google Maps Platform ライブラリを読み込む init()
メソッドを作成します。
次のコードを script.js
にコピーします。
// script.js
class PlaceFinderApp {
constructor() {
// Get all DOM element references
this.queryInput = document.getElementById('query-input');
this.priceFilter = document.getElementById('price-filter');
this.ratingFilter = document.getElementById('rating-filter');
this.openNowFilter = document.getElementById('open-now-filter');
this.searchButton = document.getElementById('search-button');
this.placeSearch = document.getElementById('place-search-list');
this.gMap = document.querySelector('gmp-map');
this.loadingSpinner = document.getElementById('loading-spinner');
this.resultsHeaderText = document.getElementById('results-header-text');
this.placeholderMessage = document.getElementById('placeholder-message');
this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');
// Initialize instance variables
this.map = null;
this.geocoder = null;
this.markers = {};
this.detailsPopup = null;
this.PriceLevel = null;
this.isSearchInProgress = false;
// Start the application
this.init();
}
async init() {
// Import libraries
await google.maps.importLibrary("maps");
const { Place, PriceLevel } = await google.maps.importLibrary("places");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
const { Geocoder } = await google.maps.importLibrary("geocoding");
// Make classes available to the instance
this.PriceLevel = PriceLevel;
this.AdvancedMarkerElement = AdvancedMarkerElement;
this.map = this.gMap.innerMap;
this.geocoder = new Geocoder();
// We will add more initialization logic here in later steps.
}
}
// Wait for the DOM to be ready, then create an instance of our app.
window.addEventListener('DOMContentLoaded', () => {
new PlaceFinderApp();
});
API キーの制限
この Codelab を動作させるには、API キーに新しい制限を追加する必要がある場合があります。詳細と手順については、API キーを制限するをご覧ください。
実施したタスクを確認する
ウェブブラウザで index.html
ファイルを開きます。検索バーとフィルタを含むヘッダー、[検索結果がここに表示されます] というメッセージが表示されたサイドバー、ニューヨーク市を中心とした大きな地図が表示されたページが表示されます。この段階では、検索コントロールはまだ機能していません。
4. 検索機能を実装する
このセクションでは、コア検索機能を実装してアプリケーションを動作させます。ユーザーが [検索] ボタンをクリックしたときに実行されるコードを記述します。この関数は、ユーザー インタラクションを適切に処理し、競合状態などの一般的なバグを防ぐために、最初からベスト プラクティスに沿って構築します。
この手順を終えると、検索ボタンをクリックして、アプリがバックグラウンドでデータを取得している間、読み込みスピナーが表示されるようになります。
検索メソッドを作成する
まず、PlaceFinderApp
クラス内で performSearch
メソッドを定義します。この関数は検索ロジックの中核となります。また、「ゲートキーパー」として機能するインスタンス変数 isSearchInProgress
も導入します。これにより、検索が進行中のときにユーザーが新しい検索を開始してエラーが発生するのを防ぐことができます。
performSearch
内のロジックは複雑に見えるかもしれませんが、分解してみましょう。
- まず、検索がすでに進行中かどうかを確認します。そうでない場合は、何も行いません。
isSearchInProgress
フラグをtrue
に設定して、関数を「ロック」します。- 読み込みスピナーを表示し、新しい結果を表示する UI を準備します。
- 検索リクエストの
textQuery
プロパティをnull
に設定します。これは、新しいリクエストが届いたことをウェブ コンポーネントに認識させるための重要なステップです。 0
遅延のあるsetTimeout
を使用します。この標準の JavaScript テクニックでは、コードの残りの部分が次のブラウザ タスクで実行されるようにスケジュール設定され、コンポーネントがnull
値を最初に処理することが保証されます。ユーザーがまったく同じものを 2 回検索した場合でも、常に新しい検索がトリガーされます。
イベント リスナーを追加する
次に、ユーザーがアプリを操作したときに performSearch
メソッドを呼び出す必要があります。すべてのイベント処理コードを 1 か所にまとめておくために、新しいメソッド attachEventListeners
を作成します。ここでは、検索ボタンの click
イベントのリスナーを追加します。また、次のステップで使用する別のイベント gmp-load
のプレースホルダも追加します。
JavaScript ファイルを更新する
script.js
ファイルを次のコードで更新します。新しいセクションまたは変更されたセクションは、attachEventListeners
メソッドと performSearch
メソッドです。
// script.js
class PlaceFinderApp {
constructor() {
// Get all DOM element references
this.queryInput = document.getElementById('query-input');
this.priceFilter = document.getElementById('price-filter');
this.ratingFilter = document.getElementById('rating-filter');
this.openNowFilter = document.getElementById('open-now-filter');
this.searchButton = document.getElementById('search-button');
this.placeSearch = document.getElementById('place-search-list');
this.gMap = document.querySelector('gmp-map');
this.loadingSpinner = document.getElementById('loading-spinner');
this.resultsHeaderText = document.getElementById('results-header-text');
this.placeholderMessage = document.getElementById('placeholder-message');
this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');
// Initialize instance variables
this.map = null;
this.geocoder = null;
this.markers = {};
this.detailsPopup = null;
this.PriceLevel = null;
this.isSearchInProgress = false;
// Start the application
this.init();
}
async init() {
// Import libraries
await google.maps.importLibrary("maps");
const { Place, PriceLevel } = await google.maps.importLibrary("places");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
const { Geocoder } = await google.maps.importLibrary("geocoding");
// Make classes available to the instance
this.PriceLevel = PriceLevel;
this.AdvancedMarkerElement = AdvancedMarkerElement;
this.map = this.gMap.innerMap;
this.geocoder = new Geocoder();
// Call the new method to set up listeners
this.attachEventListeners();
}
// NEW: Method to set up all event listeners
attachEventListeners() {
this.searchButton.addEventListener('click', this.performSearch.bind(this));
// We will add the gmp-load listener in the next step
}
// NEW: Core search method
async performSearch() {
// Exit if a search is already in progress
if (this.isSearchInProgress) {
return;
}
// Set the lock
this.isSearchInProgress = true;
// Show the placeholder and spinner
this.placeholderMessage.classList.add('hidden');
this.placeSearch.classList.remove('hidden');
this.showLoading(true);
// Force a state change by clearing the query first.
this.searchRequest.textQuery = null;
// Defer setting the real properties to the next event loop cycle.
setTimeout(async () => {
const rawQuery = this.queryInput.value.trim();
// If the query is empty, release the lock and hide the spinner
if (!rawQuery) {
this.showLoading(false);
this.isSearchInProgress = false;
return;
};
// For now, we just set the textQuery. We'll add filters later.
this.searchRequest.textQuery = rawQuery;
this.searchRequest.locationRestriction = this.map.getBounds();
}, 0);
}
// NEW: Helper method to show/hide the spinner
showLoading(visible) {
this.loadingSpinner.classList.toggle('visible', visible);
}
}
// Wait for the DOM to be ready, then create an instance of our app.
window.addEventListener('DOMContentLoaded', () => {
new PlaceFinderApp();
});
実施したタスクを確認する
script.js
ファイルを保存し、ブラウザで index.html
を更新します。ページは前と同じように表示されます。ヘッダーの [検索] ボタンをクリックします。
次の 2 つのことが起こります。
- 「検索結果がここに表示されます」というプレースホルダ メッセージが消えます。
- 読み込み中アイコンが表示され、回転し続けます。
まだ停止するタイミングを伝えていないため、スピナーは永遠に回転し続けます。次のセクションで結果を表示するときに、これを行います。これにより、検索機能が正しくトリガーされていることを確認できます。
5. 結果を表示してマーカーを追加する
検索トリガーが機能するようになったので、次は画面に結果を表示するタスクです。このセクションのコードは、検索ロジックを UI に接続します。プレイス検索要素がデータの読み込みを完了すると、検索の「ロック」が解除され、読み込みスピナーが非表示になり、結果ごとにマーカーが地図上に表示されます。
検索の完了をリッスンする
プレイス検索要素は、データを正常に取得すると gmp-load
イベントを発生させます。これは、結果を処理するのに最適なシグナルです。
まず、attachEventListeners
メソッドでこのイベントのイベント リスナーを追加します。
マーカー処理メソッドを作成する
次に、clearMarkers
と addMarkers
の 2 つの新しいヘルパー メソッドを作成します。
clearMarkers()
は、以前の検索のマーカーを削除します。addMarkers()
はgmp-load
リスナーによって呼び出されます。検索で返された場所のリストをループ処理し、それぞれに新しいAdvancedMarkerElement
を作成します。また、ここで読み込みスピナーを非表示にしてisSearchInProgress
ロックを解除し、検索サイクルを完了します。
マーカーは、プレイス ID をキーとして使用してオブジェクト(this.markers
)に保存されます。これはマーカーを管理する方法であり、後で特定のマーカーを見つけることができます。
最後に、新しい検索を開始するたびに clearMarkers()
を呼び出す必要があります。これを行うのに最適な場所は、performSearch
内です。
JavaScript ファイルを更新する
新しいメソッドと attachEventListeners
および performSearch
の変更内容で script.js
ファイルを更新します。
// script.js
class PlaceFinderApp {
constructor() {
// Get all DOM element references
this.queryInput = document.getElementById('query-input');
this.priceFilter = document.getElementById('price-filter');
this.ratingFilter = document.getElementById('rating-filter');
this.openNowFilter = document.getElementById('open-now-filter');
this.searchButton = document.getElementById('search-button');
this.placeSearch = document.getElementById('place-search-list');
this.gMap = document.querySelector('gmp-map');
this.loadingSpinner = document.getElementById('loading-spinner');
this.resultsHeaderText = document.getElementById('results-header-text');
this.placeholderMessage = document.getElementById('placeholder-message');
this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');
// Initialize instance variables
this.map = null;
this.geocoder = null;
this.markers = {};
this.detailsPopup = null;
this.PriceLevel = null;
this.isSearchInProgress = false;
// Start the application
this.init();
}
async init() {
// Import libraries
await google.maps.importLibrary("maps");
const { Place, PriceLevel } = await google.maps.importLibrary("places");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
const { Geocoder } = await google.maps.importLibrary("geocoding");
// Make classes available to the instance
this.PriceLevel = PriceLevel;
this.AdvancedMarkerElement = AdvancedMarkerElement;
this.map = this.gMap.innerMap;
this.geocoder = new Geocoder();
this.attachEventListeners();
}
attachEventListeners() {
this.searchButton.addEventListener('click', this.performSearch.bind(this));
// NEW: Listen for when the search component has loaded results
this.placeSearch.addEventListener('gmp-load', this.addMarkers.bind(this));
}
// NEW: Method to clear markers from a previous search
clearMarkers() {
for (const marker of Object.values(this.markers)) {
marker.map = null;
}
this.markers = {};
}
// NEW: Method to add markers for new search results
addMarkers() {
// Release the lock and hide the spinner
this.isSearchInProgress = false;
this.showLoading(false);
const places = this.placeSearch.places;
if (!places || places.length === 0) return;
// Create a new marker for each place result
for (const place of places) {
if (!place.location || !place.id) continue;
const marker = new this.AdvancedMarkerElement({
map: this.map,
position: place.location,
title: place.displayName,
});
// Store marker by its place ID for access later
this.markers[place.id] = marker;
}
}
async performSearch() {
if (this.isSearchInProgress) {
return;
}
this.isSearchInProgress = true;
this.placeholderMessage.classList.add('hidden');
this.placeSearch.classList.remove('hidden');
this.showLoading(true);
// NEW: Clear old markers before starting a new search
this.clearMarkers();
this.searchRequest.textQuery = null;
setTimeout(async () => {
const rawQuery = this.queryInput.value.trim();
if (!rawQuery) {
this.showLoading(false);
this.isSearchInProgress = false;
return;
};
this.searchRequest.textQuery = rawQuery;
this.searchRequest.locationRestriction = this.map.getBounds();
}, 0);
}
showLoading(visible) {
this.loadingSpinner.classList.toggle('visible', visible);
}
}
window.addEventListener('DOMContentLoaded', () => {
new PlaceFinderApp();
});
実施したタスクを確認する
ファイルを保存して、ブラウザでページを更新します。[検索] ボタンをクリックします。
読み込みスピナーがしばらく表示されてから消えます。サイドバーに検索キーワードに関連する場所の一覧が表示され、地図上には対応するマーカーが表示されます。マーカーはクリックしてもまだ何も起こりません。このインタラクティブ機能は次のセクションで追加します。
6. 検索フィルタとリストのインタラクティビティを有効にする
これで検索結果を表示できるようになりましたが、まだインタラクティブではありません。このセクションでは、すべてのユーザー コントロールを実装します。フィルタを有効にし、「Enter」キーで検索できるようにし、結果リストのアイテムを地図上の対応する場所に接続します。
このステップを完了すると、アプリケーションがユーザー入力に完全に反応するようになります。
検索フィルタを有効にする
まず、ヘッダーのすべてのフィルタ コントロールから値を読み取るように performSearch
メソッドを更新します。各フィルタ(価格、評価、「営業中」)について、検索が実行される前に、対応するプロパティが searchRequest
オブジェクトに設定されます。
すべてのコントロールにイベント リスナーを追加する
次に、attachEventListeners
メソッドを拡張します。各フィルタ コントロールに change
イベントのリスナーを追加し、検索入力に keydown
リスナーを追加して、ユーザーが Enter キーを押したことを検出します。これらの新しいリスナーはすべて performSearch
メソッドを呼び出します。
検索結果リストを地図に接続する
シームレスなエクスペリエンスを実現するには、サイドバーの結果リストでアイテムをクリックしたときに、地図がその場所にフォーカスされるようにする必要があります。
新しいメソッド handleResultClick
は、アイテムがクリックされたときに Place Search Element によって発生する gmp-select
イベントをリッスンします。この関数は、関連付けられた場所の位置を特定し、地図をその場所にスムーズにパンします。
この処理が機能するには、index.html
の gmp-place-search
コンポーネントに selectable
属性が存在していることを確認してください。
<gmp-place-search id="place-search-list" class="hidden" selectable>
<gmp-place-all-content></gmp-place-all-content>
<gmp-place-text-search-request></gmp-place-text-search-request>
</gmp-place-search>
JavaScript ファイルを更新する
次の完全なコードで script.js
ファイルを更新します。このバージョンには、新しい handleResultClick
メソッドと、attachEventListeners
および performSearch
の更新されたロジックが含まれています。
// script.js
class PlaceFinderApp {
constructor() {
// Get all DOM element references
this.queryInput = document.getElementById('query-input');
this.priceFilter = document.getElementById('price-filter');
this.ratingFilter = document.getElementById('rating-filter');
this.openNowFilter = document.getElementById('open-now-filter');
this.searchButton = document.getElementById('search-button');
this.placeSearch = document.getElementById('place-search-list');
this.gMap = document.querySelector('gmp-map');
this.loadingSpinner = document.getElementById('loading-spinner');
this.resultsHeaderText = document.getElementById('results-header-text');
this.placeholderMessage = document.getElementById('placeholder-message');
this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');
// Initialize instance variables
this.map = null;
this.geocoder = null;
this.markers = {};
this.detailsPopup = null;
this.PriceLevel = null;
this.isSearchInProgress = false;
// Start the application
this.init();
}
async init() {
// Import libraries
await google.maps.importLibrary("maps");
const { Place, PriceLevel } = await google.maps.importLibrary("places");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
const { Geocoder } = await google.maps.importLibrary("geocoding");
// Make classes available to the instance
this.PriceLevel = PriceLevel;
this.AdvancedMarkerElement = AdvancedMarkerElement;
this.map = this.gMap.innerMap;
this.geocoder = new Geocoder();
this.attachEventListeners();
}
// UPDATED: All event listeners are now attached
attachEventListeners() {
// Listen for the 'Enter' key press in the search input
this.queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.performSearch();
}
});
// Listen for a sidebar result click
this.placeSearch.addEventListener('gmp-select', this.handleResultClick.bind(this));
this.placeSearch.addEventListener('gmp-load', this.addMarkers.bind(this));
this.searchButton.addEventListener('click', this.performSearch.bind(this));
this.priceFilter.addEventListener('change', this.performSearch.bind(this));
this.ratingFilter.addEventListener('change', this.performSearch.bind(this));
this.openNowFilter.addEventListener('change', this.performSearch.bind(this));
}
clearMarkers() {
for (const marker of Object.values(this.markers)) {
marker.map = null;
}
this.markers = {};
}
addMarkers() {
this.isSearchInProgress = false;
this.showLoading(false);
const places = this.placeSearch.places;
if (!places || places.length === 0) return;
for (const place of places) {
if (!place.location || !place.id) continue;
const marker = new this.AdvancedMarkerElement({
map: this.map,
position: place.location,
title: place.displayName,
});
this.markers[place.id] = marker;
}
}
// NEW: Function to handle clicks on the results list
handleResultClick(event) {
const place = event.place;
if (!place || !place.location) return;
// Pan the map to the selected place
this.map.panTo(place.location);
}
// UPDATED: Search function now includes all filters
async performSearch() {
if (this.isSearchInProgress) {
return;
}
this.isSearchInProgress = true;
this.placeholderMessage.classList.add('hidden');
this.placeSearch.classList.remove('hidden');
this.showLoading(true);
this.clearMarkers();
this.searchRequest.textQuery = null;
setTimeout(async () => {
const rawQuery = this.queryInput.value.trim();
if (!rawQuery) {
this.showLoading(false);
this.isSearchInProgress = false;
return;
};
this.searchRequest.textQuery = rawQuery;
this.searchRequest.locationRestriction = this.map.getBounds();
// Add filter values to the request
const selectedPrice = this.priceFilter.value;
let priceLevels = [];
switch (selectedPrice) {
case "1": priceLevels = [this.PriceLevel.INEXPENSIVE]; break;
case "2": priceLevels = [this.PriceLevel.MODERATE]; break;
case "3": priceLevels = [this.PriceLevel.EXPENSIVE]; break;
case "4": priceLevels = [this.PriceLevel.VERY_EXPENSIVE]; break;
default: priceLevels = null; break;
}
this.searchRequest.priceLevels = priceLevels;
const selectedRating = parseFloat(this.ratingFilter.value);
this.searchRequest.minRating = selectedRating > 0 ? selectedRating : null;
this.searchRequest.isOpenNow = this.openNowFilter.checked ? true : null;
}, 0);
}
showLoading(visible) {
this.loadingSpinner.classList.toggle('visible', visible);
}
}
window.addEventListener('DOMContentLoaded', () => {
new PlaceFinderApp();
});
実施したタスクを確認する
script.js
ファイルを保存してページを更新します。これで、アプリのインタラクティブ性が高まります。
以下を確認します。
- 検索ボックスで Enter キーを押して検索することはできます。
- フィルタ(料金、評価、現在営業中)を変更すると、新しい検索がトリガーされ、結果が更新されます。
- サイドバーの検索結果リストでアイテムをクリックすると、地図がそのアイテムの場所にスムーズに移動するようになりました。
次のセクションでは、マーカーがクリックされたときに表示される詳細カードを実装します。
7. Place Details 要素を実装する
これでアプリは完全にインタラクティブになりましたが、選択した場所の詳細情報を表示する機能という重要な機能が欠けています。このセクションでは、ユーザーが地図上のマーカーをクリックしたとき、または Place Search Element で項目を選択したときに表示される Place Details Element を実装します。
再利用可能な詳細カード コンテナを作成する
地図上に場所の詳細を最も効率的に表示する方法は、再利用可能な単一のコンテナを作成することです。このコンテナとして AdvancedMarkerElement
を使用します。そのコンテンツは、index.html
にすでにある非表示の gmp-place-details-compact
ウィジェットになります。
新しいメソッド initDetailsPopup
は、この再利用可能なマーカーの作成を処理します。アプリケーションの読み込み時に 1 回作成され、非表示の状態で開始されます。また、このメソッドでメインの地図にリスナーを追加し、地図の任意の場所をクリックすると詳細カードが非表示になるようにします。
マーカーのクリック動作を更新する
次に、ユーザーがプレイスマーカーをクリックしたときの動作を更新する必要があります。addMarkers
メソッド内の 'click'
リスナーが、詳細カードの表示を担当するようになります。
マーカーがクリックされると、リスナーは次の処理を行います。
- 地図をマーカーの位置まで移動します。
- その特定の場所の情報で詳細カードを更新します。
- 詳細カードをマーカーの位置に配置し、表示します。
リストのクリックをマーカーのクリックに接続する
最後に、handleResultClick
メソッドを更新します。地図をパンするだけでなく、対応するマーカーで click
イベントがプログラムでトリガーされるようになります。これは、両方のインタラクションでまったく同じロジックを再利用できる強力なパターンであり、コードをクリーンで保守しやすい状態に保つことができます。
JavaScript ファイルを更新する
script.js
ファイルを次のコードで更新します。新しいセクションまたは変更されたセクションは、initDetailsPopup
メソッドと、更新された addMarkers
メソッドと handleResultClick
メソッドです。
// script.js
class PlaceFinderApp {
constructor() {
// Get all DOM element references
this.queryInput = document.getElementById('query-input');
this.priceFilter = document.getElementById('price-filter');
this.ratingFilter = document.getElementById('rating-filter');
this.openNowFilter = document.getElementById('open-now-filter');
this.searchButton = document.getElementById('search-button');
this.placeSearch = document.getElementById('place-search-list');
this.gMap = document.querySelector('gmp-map');
this.loadingSpinner = document.getElementById('loading-spinner');
this.resultsHeaderText = document.getElementById('results-header-text');
this.placeholderMessage = document.getElementById('placeholder-message');
this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');
// Initialize instance variables
this.map = null;
this.geocoder = null;
this.markers = {};
this.detailsPopup = null;
this.PriceLevel = null;
this.isSearchInProgress = false;
// Start the application
this.init();
}
async init() {
// Import libraries
await google.maps.importLibrary("maps");
const { Place, PriceLevel } = await google.maps.importLibrary("places");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
const { Geocoder } = await google.maps.importLibrary("geocoding");
// Make classes available to the instance
this.PriceLevel = PriceLevel;
this.AdvancedMarkerElement = AdvancedMarkerElement;
this.map = this.gMap.innerMap;
this.geocoder = new Geocoder();
// NEW: Call the method to initialize the details card
this.initDetailsPopup();
this.attachEventListeners();
}
attachEventListeners() {
this.queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.performSearch();
}
});
this.placeSearch.addEventListener('gmp-select', this.handleResultClick.bind(this));
this.placeSearch.addEventListener('gmp-load', this.addMarkers.bind(this));
this.searchButton.addEventListener('click', this.performSearch.bind(this));
this.priceFilter.addEventListener('change', this.performSearch.bind(this));
this.ratingFilter.addEventListener('change', this.performSearch.bind(this));
this.openNowFilter.addEventListener('change', this.performSearch.bind(this));
}
// NEW: Method to set up the reusable details card
initDetailsPopup() {
this.detailsPopup = new this.AdvancedMarkerElement({
content: this.placeDetailsWidget,
map: null,
zIndex: 100
});
this.map.addListener('click', () => { this.detailsPopup.map = null; });
}
clearMarkers() {
for (const marker of Object.values(this.markers)) {
marker.map = null;
}
this.markers = {};
}
// UPDATED: The marker's click listener now shows the details card
addMarkers() {
this.isSearchInProgress = false;
this.showLoading(false);
const places = this.placeSearch.places;
if (!places || places.length === 0) return;
for (const place of places) {
if (!place.location || !place.id) continue;
const marker = new this.AdvancedMarkerElement({
map: this.map,
position: place.location,
title: place.displayName,
});
// Add the click listener to show the details card
marker.addListener('click', (event) => {
event.stop();
this.map.panTo(place.location);
this.placeDetailsRequest.place = place;
this.placeDetailsWidget.style.display = 'block';
this.detailsPopup.position = place.location;
this.detailsPopup.map = this.map;
});
this.markers[place.id] = marker;
}
}
// UPDATED: This now triggers the marker's click event
handleResultClick(event) {
const place = event.place;
if (!place || !place.id) return;
const marker = this.markers[place.id];
if (marker) {
// Programmatically trigger the marker's click event
marker.click();
}
}
async performSearch() {
if (this.isSearchInProgress) return;
this.isSearchInProgress = true;
this.placeholderMessage.classList.add('hidden');
this.placeSearch.classList.remove('hidden');
this.showLoading(true);
this.clearMarkers();
// Hide the details card when a new search starts
if (this.detailsPopup) this.detailsPopup.map = null;
this.searchRequest.textQuery = null;
setTimeout(async () => {
const rawQuery = this.queryInput.value.trim();
if (!rawQuery) {
this.showLoading(false);
this.isSearchInProgress = false;
return;
};
this.searchRequest.textQuery = rawQuery;
this.searchRequest.locationRestriction = this.map.getBounds();
const selectedPrice = this.priceFilter.value;
let priceLevels = [];
switch (selectedPrice) {
case "1": priceLevels = [this.PriceLevel.INEXPENSIVE]; break;
case "2": priceLevels = [this.PriceLevel.MODERATE]; break;
case "3": priceLevels = [this.PriceLevel.EXPENSIVE]; break;
case "4": priceLevels = [this.PriceLevel.VERY_EXPENSIVE]; break;
default: priceLevels = null; break;
}
this.searchRequest.priceLevels = priceLevels;
const selectedRating = parseFloat(this.ratingFilter.value);
this.searchRequest.minRating = selectedRating > 0 ? selectedRating : null;
this.searchRequest.isOpenNow = this.openNowFilter.checked ? true : null;
}, 0);
}
showLoading(visible) {
this.loadingSpinner.classList.toggle('visible', visible);
}
}
window.addEventListener('DOMContentLoaded', () => {
new PlaceFinderApp();
});
実施したタスクを確認する
script.js
ファイルを保存してページを更新します。これで、オンデマンドで詳細が表示されるようになります。
以下を確認します。
- 地図上のマーカーをクリックすると、地図が中央に移動し、マーカーの上にスタイル設定された詳細カードが開くようになりました。
- サイドバーの結果リストでアイテムをクリックした場合も、同じ動作になります。
- 地図上のカード以外の場所をクリックすると、カードが閉じます。
- 新しい検索を開始すると、開いている詳細カードも閉じられます。
8. 最終的な仕上げを行う
これでアプリケーションは完全に機能するようになりましたが、ユーザー エクスペリエンスをさらに向上させるために、いくつかの仕上げを追加できます。最後のセクションでは、検索結果のコンテキストをより適切に提供する動的なヘッダーと、ユーザーの検索クエリの自動フォーマットという 2 つの重要な機能を実装します。
動的な結果ヘッダーを作成する
現在、サイドバーのヘッダーには常に「結果」と表示されます。この情報を現在の検索結果に合わせて更新することで、より有益な情報を提供できるようになります。たとえば、「ニューヨークの近くのハンバーガー店」などです。
そのため、Geocoding API を使用して、地図の中心座標を都市名などの人が読める場所に変換します。新しい async
メソッド updateResultsHeader
がこのロジックを処理します。検索が実行されるたびに呼び出されます。
ユーザーの検索クエリをフォーマットする
UI をすっきりと一貫性のあるものにするため、ユーザーの検索語句は自動的に「タイトルケース」(例: 「burger restaurant」は「Burger Restaurant」になります。ヘルパー関数 toTitleCase
がこの変換を処理します。performSearch
メソッドは、検索を実行してヘッダーを更新する前に、ユーザーの入力に対してこの関数を使用するように更新されます。
JavaScript ファイルを更新する
script.js
ファイルをコードの最終バージョンで更新します。これには、新しい toTitleCase
メソッドと updateResultsHeader
メソッド、およびそれらを統合する更新された performSearch
メソッドが含まれます。
// script.js
class PlaceFinderApp {
constructor() {
// Get all DOM element references
this.queryInput = document.getElementById('query-input');
this.priceFilter = document.getElementById('price-filter');
this.ratingFilter = document.getElementById('rating-filter');
this.openNowFilter = document.getElementById('open-now-filter');
this.searchButton = document.getElementById('search-button');
this.placeSearch = document.getElementById('place-search-list');
this.gMap = document.querySelector('gmp-map');
this.loadingSpinner = document.getElementById('loading-spinner');
this.resultsHeaderText = document.getElementById('results-header-text');
this.placeholderMessage = document.getElementById('placeholder-message');
this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');
// Initialize instance variables
this.map = null;
this.geocoder = null;
this.markers = {};
this.detailsPopup = null;
this.PriceLevel = null;
this.isSearchInProgress = false;
// Start the application
this.init();
}
async init() {
// Import libraries
await google.maps.importLibrary("maps");
const { Place, PriceLevel } = await google.maps.importLibrary("places");
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
const { Geocoder } = await google.maps.importLibrary("geocoding");
// Make classes available to the instance
this.PriceLevel = PriceLevel;
this.AdvancedMarkerElement = AdvancedMarkerElement;
this.map = this.gMap.innerMap;
this.geocoder = new Geocoder();
this.initDetailsPopup();
this.attachEventListeners();
}
attachEventListeners() {
this.queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.performSearch();
}
});
this.placeSearch.addEventListener('gmp-select', this.handleResultClick.bind(this));
this.placeSearch.addEventListener('gmp-load', this.addMarkers.bind(this));
this.searchButton.addEventListener('click', this.performSearch.bind(this));
this.priceFilter.addEventListener('change', this.performSearch.bind(this));
this.ratingFilter.addEventListener('change', this.performSearch.bind(this));
this.openNowFilter.addEventListener('change', this.performSearch.bind(this));
}
initDetailsPopup() {
this.detailsPopup = new this.AdvancedMarkerElement({
content: this.placeDetailsWidget,
map: null,
zIndex: 100
});
this.map.addListener('click', () => { this.detailsPopup.map = null; });
}
// NEW: Helper function to format text to Title Case
toTitleCase(str) {
if (!str) return '';
return str.toLowerCase().split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
}
showLoading(visible) {
this.loadingSpinner.classList.toggle('visible', visible);
}
clearMarkers() {
for (const marker of Object.values(this.markers)) { marker.map = null; }
this.markers = {};
}
addMarkers() {
this.isSearchInProgress = false;
this.showLoading(false);
const places = this.placeSearch.places;
if (!places || places.length === 0) return;
for (const place of places) {
if (!place.location || !place.id) continue;
const marker = new this.AdvancedMarkerElement({
map: this.map,
position: place.location,
title: place.displayName,
});
marker.addListener('click', (event) => {
event.stop();
this.map.panTo(place.location);
this.placeDetailsRequest.place = place;
this.placeDetailsWidget.style.display = 'block';
this.detailsPopup.position = place.location;
this.detailsPopup.map = this.map;
});
this.markers[place.id] = marker;
}
}
handleResultClick(event) {
const place = event.place;
if (!place || !place.id) return;
const marker = this.markers[place.id];
if (marker) {
marker.click();
}
}
// UPDATED: Now integrates formatting and the dynamic header
async performSearch() {
if (this.isSearchInProgress) return;
this.isSearchInProgress = true;
this.placeholderMessage.classList.add('hidden');
this.placeSearch.classList.remove('hidden');
this.showLoading(true);
this.clearMarkers();
if (this.detailsPopup) this.detailsPopup.map = null;
this.searchRequest.textQuery = null;
setTimeout(async () => {
const rawQuery = this.queryInput.value.trim();
if (!rawQuery) {
this.showLoading(false);
this.isSearchInProgress = false;
return;
};
// Format the query and update the input box value
const formattedQuery = this.toTitleCase(rawQuery);
this.queryInput.value = formattedQuery;
// Update the header with the new query and location
await this.updateResultsHeader(formattedQuery);
// Pass the formatted query to the search request
this.searchRequest.textQuery = formattedQuery;
this.searchRequest.locationRestriction = this.map.getBounds();
const selectedPrice = this.priceFilter.value;
let priceLevels = [];
switch (selectedPrice) {
case "1": priceLevels = [this.PriceLevel.INEXPENSIVE]; break;
case "2": priceLevels = [this.PriceLevel.MODERATE]; break;
case "3": priceLevels = [this.PriceLevel.EXPENSIVE]; break;
case "4": priceLevels = [this.PriceLevel.VERY_EXPENSIVE]; break;
default: priceLevels = null; break;
}
this.searchRequest.priceLevels = priceLevels;
const selectedRating = parseFloat(this.ratingFilter.value);
this.searchRequest.minRating = selectedRating > 0 ? selectedRating : null;
this.searchRequest.isOpenNow = this.openNowFilter.checked ? true : null;
}, 0);
}
// NEW: Method to update the sidebar header with geocoded location
async updateResultsHeader(query) {
try {
const response = await this.geocoder.geocode({ location: this.map.getCenter() });
if (response.results && response.results.length > 0) {
const cityResult = response.results.find(r => r.types.includes('locality')) || response.results[0];
const city = cityResult.address_components[0].long_name;
this.resultsHeaderText.textContent = `${query} near ${city}`;
} else {
this.resultsHeaderText.textContent = `${query} near current map area`;
}
} catch (error) {
console.error("Geocoding failed:", error);
this.resultsHeaderText.textContent = `Results for ${query}`;
}
}
}
window.addEventListener('DOMContentLoaded', () => {
new PlaceFinderApp();
});
実施したタスクを確認する
script.js
ファイルを保存してページを更新します。
機能を確認します。
- 検索ボックスに「
pizza
」(すべて小文字)と入力して検索をクリックします。ボックス内のテキストが「ピザ」に変わり、サイドバーの見出しが「ニューヨークの近くのピザ屋」に更新されます。 - 地図をボストンなどの別の都市に移動して、もう一度検索します。ヘッダーが「ボストン近郊のピザ」に更新されます。
9. 完了
Places UI キットのシンプルさと Google Maps Platform JavaScript API のパワーを組み合わせた、完全なインタラクティブなローカル検索アプリケーションが正常に作成されました。
学習した内容
- JavaScript クラスを使用して状態とロジックを管理するマッピング アプリケーションを構築する方法。
- Google Maps JavaScript API で Places UI キットを使用して UI を迅速に開発する方法。
- 高度なマーカーをプログラムで追加、管理して、地図上にカスタム スポットを表示する方法。
- ジオコーディング サービスを使用して座標を人が読める住所に変換し、ユーザー エクスペリエンスを向上させる方法。
- 状態フラグを使用して、コンポーネントのプロパティが正しく更新されるようにすることで、インタラクティブ アプリケーションで一般的な競合状態を特定して修正する方法。
次のステップ
- 色や縮尺を変更したり、カスタム HTML を使用したりして、高度なマーカーをカスタマイズする方法についてご確認ください。
- Cloud ベースのマップのスタイル設定で、地図のデザインと操作性をカスタマイズしてブランドに合わせます。
- 図形描画ライブラリを追加して、ユーザーが地図上にシェイプを描画して検索エリアを定義できるようにします。
- 皆様のお役に立つコンテンツをご提供できるよう、以下のアンケートにご協力ください。
他にどのような Codelab をご希望ですか。
最も関心のある Codelab が見つからない場合は、こちらからリクエストしてください。