使用 Places UI Kit 构建本地搜索应用

1. 准备工作

此 Codelab 会教您如何使用 Google Maps Platform Places UI Kit 构建完全互动的本地搜索应用。

PlaceFinder 应用的成品屏幕截图,其中显示了带有标记的纽约地图、带有搜索结果的边栏以及打开的详情卡片。

前提条件

  • 已配置必要 API 和凭据的 Google Cloud 项目。
  • 具备 HTML 和 CSS 方面的基础知识。
  • 了解现代 JavaScript。
  • 新版网络浏览器,例如最新版 Chrome。
  • 您选择的文本编辑器。

您将执行的操作

  • 使用 JavaScript 类构建地图应用。
  • 使用 Web 组件显示地图
  • 使用地点搜索元素执行文本搜索并显示结果。
  • 以编程方式创建和管理自定义 AdvancedMarkerElement 地图标记。
  • 在用户选择某个位置时显示地点详情元素。
  • 使用 Geocoding API 创建动态且用户友好的界面。

所需条件

  • 启用了结算功能的 Google Cloud 项目
  • Google Maps Platform API 密钥
  • 地图 ID
  • 已启用以下 API:
    • Maps JavaScript API
    • Places UI Kit
    • Geocoding API

2. 进行设置

若要完成下面的启用步骤,您需要启用 Maps JavaScript API、Places 界面套件和 Geocoding API。

设置 Google Maps Platform

如果您还没有已启用结算功能的 Google Cloud Platform 账号和项目,请参阅 Google Maps Platform 使用入门指南,创建结算账号和项目。

  1. 在 Cloud Console 中,点击项目下拉菜单,选择要用于此 Codelab 的项目。

  1. 在 Google Cloud Marketplace 中启用此 Codelab 所需的 Google Maps Platform API 和 SDK。为此,请按照此视频或此文档中的步骤操作。
  2. 在 Cloud Console 的凭据页面中生成 API 密钥。您可以按照此视频或此文档中的步骤操作。向 Google Maps Platform 发出的所有请求都需要 API 密钥。

3. 应用 shell 和功能地图

在第一步中,我们将为应用创建完整的视觉布局,并为 JavaScript 建立清晰的基于类的结构。这为我们奠定了坚实的基础。完成本部分学习后,您将获得一个显示互动式地图的样式化网页。

创建 HTML 文件

首先,创建一个名为 index.html 的文件。此文件将包含应用的完整结构,包括标题、搜索过滤条件、边栏、地图容器和必要的 Web 组件。

将以下代码复制到 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 可处理所有界面元素的整体布局、颜色、字体和外观。

将以下代码复制到 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 元素,并创建一个 init() 方法来加载 Google Maps Platform 库。

将以下代码复制到 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 密钥限制

您可能需要为 API 密钥添加新限制,才能顺利完成此 Codelab。如需详细了解如何执行此操作,请参阅限制 API 密钥。

检查您的工作

在网络浏览器中打开 index.html 文件。您应该会看到一个页面,其中包含一个带有搜索栏和过滤条件的标题、一个带有“您的搜索结果将显示在此处”消息的边栏,以及一张以纽约市为中心的大地图。在此阶段,搜索控件尚未正常运行。

4. 实现搜索功能

在本部分中,我们将实现核心搜索功能,让应用真正发挥作用。我们将编写在用户点击“搜索”按钮时运行的代码。我们将从一开始就按照最佳实践来构建此函数,以便妥善处理用户互动并防止出现竞态条件等常见 bug。

完成此步骤后,您将能够点击搜索按钮,并在应用在后台提取数据时看到加载微调框。

创建搜索方法

首先,在 PlaceFinderApp 类中定义 performSearch 方法。此函数将是搜索逻辑的核心。我们还将引入一个实例变量 isSearchInProgress,用作“门卫”。这样可以防止用户在搜索正在进行时开始新的搜索,从而避免出现错误。

performSearch 中的逻辑可能看起来很复杂,下面我们来详细介绍一下:

  1. 它会先检查是否已在进行搜索。如果为 true,则不执行任何操作。
  2. 它将 isSearchInProgress 标志设置为 true 以“锁定”该函数。
  3. 它会显示加载微调框,并为新结果准备界面。
  4. 它将搜索请求的 textQuery 属性设置为 null。这是一个关键步骤,可强制 Web 组件识别出有新请求到来。
  5. 它使用具有 0 延迟的 setTimeout。此标准 JavaScript 技术会安排其余代码在下一个浏览器任务中运行,确保组件先处理 null 值。即使同一用户两次搜索完全相同的内容,系统也始终会触发新的搜索。

添加事件监听器

接下来,我们需要在用户与应用互动时调用 performSearch 方法。我们将创建一个新方法 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。页面应与之前相同。现在,点击标题中的“搜索”按钮。

您应该会看到以下两种情况:

  1. 占位消息“您的搜索结果将显示在此处”消失。
  2. 加载旋转图标会显示并继续旋转。

由于我们尚未告知旋转器何时停止,因此它会一直旋转。我们将在下一部分中显示结果时执行此操作。这可确认我们的搜索功能是否已正确触发。

5. 显示结果并添加标记

现在,搜索触发器已正常运行,下一个任务是在屏幕上显示结果。此部分中的代码会将搜索逻辑与界面相关联。地点搜索元素完成数据加载后,将释放搜索“锁定”,隐藏加载微调器,并在地图上为每个结果显示一个标记。

监听搜索完成情况

当地点搜索元素成功提取数据时,会触发 gmp-load 事件。这是我们处理结果的完美信号。

首先,在 attachEventListeners 方法中为此事件添加一个事件监听器。

创建标记处理方法

接下来,我们将创建两个新的辅助方法:clearMarkers 和 addMarkers。

  • 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 将监听 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. 实现地点详情元素

我们的应用现在已完全具备互动性,但缺少一项关键功能:能够查看所选地点的更多信息。在本部分中,我们将实现地点详情元素,该元素会在用户点击地图上的标记或在地点搜索元素中选择某个项时显示。

创建可重复使用的详情卡片容器

在地图上显示地点详细信息的最有效方法是创建一个可重复使用的单个容器。我们将使用 AdvancedMarkerElement 作为此容器。其内容将是 index.html 中已有的隐藏 gmp-place-details-compact widget。

新方法 initDetailsPopup 将负责创建此可重复使用的标记。它会在应用加载时创建一次,并以隐藏状态启动。我们还会在该方法中向主地图添加一个监听器,以便点击地图上的任意位置即可隐藏详情卡片。

更新标记点击行为

接下来,我们需要更新用户点击地点标记时发生的情况。现在,addMarkers 方法内的 'click' 监听器将负责显示详情卡片。

当用户点击标记时,监听器将执行以下操作:

  1. 平移地图到标记的位置。
  2. 使用相应特定地点的信息更新详情卡片。
  3. 将详情卡片放置在标记的位置,并使其可见。

将列表点击与标记点击相关联

最后,我们将更新 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. 添加最终润饰

我们的应用现在已完全正常运行,但我们可以再添加一些润色,让用户体验更上一层楼。在最后一部分中,我们将实现两项关键功能:提供更出色的搜索结果上下文的动态标题,以及针对用户搜索查询的自动格式设置。

创建动态结果标题

目前,边栏标题始终显示“结果”。我们可以更新此消息,使其反映当前搜索,从而提供更丰富的信息。例如,“纽约附近的汉堡店”。

为此,我们将使用地理编码 API 将地图的中心坐标转换为直观易懂的位置信息(例如城市名称)。新的 async 方法 updateResultsHeader 将处理此逻辑。每次执行搜索时都会调用此方法。

设置用户搜索查询的格式

为确保界面看起来简洁一致,我们会自动将用户的搜索字词格式化为“首字母大写”格式(例如,“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 Kit 的简洁性与核心 Google Maps Platform JavaScript API 的强大功能相结合。

要点回顾

  • 如何使用 JavaScript 类来管理状态和逻辑,从而构建地图应用。
  • 如何将 Places UI Kit 与 Google Maps JavaScript API 搭配使用,以快速开发界面。
  • 如何以编程方式添加和管理高级标记,以便在地图上显示自定义地图注点。
  • 如何使用 Geocoding Service 将坐标转换为直观易懂的地址,从而提升用户体验。
  • 如何使用状态标志识别并修复互动应用中的常见竞态条件,并确保组件属性得到正确更新。

后续操作

  • 详细了解如何通过更改高级标记的颜色、缩放比例甚至使用自定义 HTML 来自定义高级标记。
  • 探索云端地图样式设置,自定义地图的外观和风格,使其与您的品牌相配。
  • 您可以尝试添加绘图库,以便用户在地图上绘制形状来定义搜索区域。
  • 请回答以下调查问卷,帮助我们为您制作最为有用的内容:

您还想查看其他哪些 Codelab?

地图上的数据可视化 更多关于自定义地图样式的信息 在地图中构建 3D 交互

找不到您最感兴趣的 Codelab?没关系,请在此处通过创建新问题的方式申请 Codelab。