สร้างแอปค้นหาในพื้นที่ด้วย Places UI Kit

1. ก่อนเริ่มต้น

Codelab นี้จะสอนวิธีสร้างแอปพลิเคชันการค้นหาในพื้นที่แบบอินเทอร์แอกทีฟเต็มรูปแบบโดยใช้ชุดเครื่องมือ UI ของ Places ใน Google Maps Platform

ภาพหน้าจอของแอปพลิเคชัน PlaceFinder ที่เสร็จสมบูรณ์แล้ว ซึ่งแสดงแผนที่ของนิวยอร์กพร้อมเครื่องหมาย แถบด้านข้างที่มีผลการค้นหา และการ์ดรายละเอียดopen

ข้อกำหนดเบื้องต้น

  • โปรเจ็กต์ Google Cloud ที่กำหนดค่า API และข้อมูลเข้าสู่ระบบที่จำเป็น
  • มีความรู้พื้นฐานเกี่ยวกับ HTML และ CSS
  • ความเข้าใจเกี่ยวกับ JavaScript ที่ทันสมัย
  • เว็บเบราว์เซอร์รุ่นใหม่ เช่น Chrome เวอร์ชันล่าสุด
  • โปรแกรมแก้ไขข้อความที่คุณเลือก

สิ่งที่คุณต้องดำเนินการ

  • สร้างแอปพลิเคชันการแมปโดยใช้คลาส JavaScript
  • ใช้ Web Components เพื่อแสดงแผนที่
  • ใช้องค์ประกอบการค้นหาสถานที่เพื่อดำเนินการและแสดงผลการค้นหาข้อความ
  • สร้างและจัดการเครื่องหมายแผนที่ AdvancedMarkerElement ที่กำหนดเองโดยใช้โปรแกรม
  • แสดงองค์ประกอบรายละเอียดสถานที่เมื่อผู้ใช้เลือกสถานที่
  • ใช้ Geocoding API เพื่อสร้างอินเทอร์เฟซแบบไดนามิกและใช้งานง่าย

สิ่งที่คุณต้องมี

  • โปรเจ็กต์ Google Cloud ที่เปิดใช้การเรียกเก็บเงิน
  • คีย์ API ของ Google Maps Platform
  • รหัสแผนที่
  • เปิดใช้ API ต่อไปนี้
    • Maps JavaScript API
    • Places UI Kit
    • Geocoding API

2. ตั้งค่า

สำหรับขั้นตอนการเปิดใช้ต่อไปนี้ คุณจะต้องเปิดใช้ Maps JavaScript API, Places UI Kit และ Geocoding API

ตั้งค่า Google Maps Platform

หากยังไม่มีบัญชี Google Cloud Platform และโปรเจ็กต์ที่เปิดใช้การเรียกเก็บเงิน โปรดดูคู่มือเริ่มต้นใช้งาน Google Maps Platform เพื่อสร้างบัญชีสำหรับการเรียกเก็บเงินและโปรเจ็กต์

  1. ใน Cloud Console ให้คลิกเมนูแบบเลื่อนลงของโปรเจ็กต์ แล้วเลือกโปรเจ็กต์ที่ต้องการใช้สำหรับ Codelab นี้

  1. เปิดใช้ Google Maps Platform APIs และ SDK ที่จำเป็นสำหรับ Codelab นี้ใน Google Cloud Marketplace โดยทำตามขั้นตอนในวิดีโอนี้หรือเอกสารประกอบนี้
  2. สร้างคีย์ API ในหน้าข้อมูลเข้าสู่ระบบของ Cloud Console คุณสามารถทำตามขั้นตอนในวิดีโอนี้หรือเอกสารประกอบนี้ คำขอทั้งหมดไปยัง Google Maps Platform ต้องใช้คีย์ API

3. Application Shell และแผนที่การทำงาน

ในขั้นตอนแรกนี้ เราจะสร้างเลย์เอาต์ภาพที่สมบูรณ์สำหรับแอปพลิเคชันและสร้างโครงสร้างที่สะอาดและอิงตามคลาสสำหรับ JavaScript ซึ่งช่วยให้เรามีรากฐานที่มั่นคงในการต่อยอด เมื่อสิ้นสุดส่วนนี้ คุณจะมีหน้าเว็บที่จัดรูปแบบซึ่งแสดงแผนที่แบบอินเทอร์แอกทีฟ

สร้างไฟล์ HTML

ก่อนอื่นให้สร้างไฟล์ชื่อ index.html ไฟล์นี้จะมีโครงสร้างทั้งหมดของแอปพลิเคชัน ซึ่งรวมถึงส่วนหัว ตัวกรองการค้นหา แถบด้านข้าง คอนเทนเนอร์แผนที่ และคอมโพเนนต์เว็บที่จำเป็น

คัดลอกโค้ดต่อไปนี้ลงใน index.html อย่าลืมแทนที่ YOUR_API_KEY_HERE ด้วยคีย์ API ของ Google Maps Platform ของคุณเอง และ DEMO_MAP_ID ด้วยรหัสแผนที่ Google Maps Platform ของคุณเอง

<!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 เราจะจัดโครงสร้างแอปพลิเคชันภายในคลาส JavaScript ที่ชื่อ PlaceFinderApp ซึ่งจะช่วยให้โค้ดของเราเป็นระเบียบและจัดการสถานะได้อย่างสะอาด

โค้ดเริ่มต้นนี้จะกำหนดคลาส ค้นหาองค์ประกอบ HTML ทั้งหมดใน constructor และสร้างเมธอด 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. ใช้ฟังก์ชันค้นหา

ในส่วนนี้ เราจะทำให้แอปพลิเคชันของเราใช้งานได้จริงโดยการติดตั้งใช้งานฟังก์ชันการค้นหาหลัก เราจะเขียนโค้ดที่จะทำงานเมื่อผู้ใช้คลิกปุ่ม "ค้นหา" เราจะสร้างฟังก์ชันนี้ตามแนวทางปฏิบัติแนะนำตั้งแต่เริ่มต้นเพื่อจัดการการโต้ตอบของผู้ใช้อย่างราบรื่นและป้องกันข้อบกพร่องที่พบบ่อย เช่น สภาวะการแข่งขัน

เมื่อสิ้นสุดขั้นตอนนี้ คุณจะคลิกปุ่มค้นหาและเห็นวงกลมหมุนแสดงการโหลดขณะที่แอปพลิเคชันดึงข้อมูลในเบื้องหลังได้

สร้างวิธีการค้นหา

ก่อนอื่น ให้กำหนดperformSearchเมธอดภายในคลาส PlaceFinderApp ฟังก์ชันนี้จะเป็นหัวใจสำคัญของตรรกะการค้นหา นอกจากนี้ เราจะเปิดตัวตัวแปรอินสแตนซ์ isSearchInProgress เพื่อทำหน้าที่เป็น "ผู้ดูแล" การดำเนินการนี้จะป้องกันไม่ให้ผู้ใช้เริ่มการค้นหาใหม่ในขณะที่การค้นหาหนึ่งกำลังดำเนินการอยู่ ซึ่งอาจทำให้เกิดข้อผิดพลาดได้

ตรรกะภายใน performSearch อาจดูซับซ้อน เราจึงจะอธิบายให้เข้าใจง่ายขึ้น

  1. โดยจะตรวจสอบก่อนว่ามีการค้นหาที่กำลังดำเนินการอยู่หรือไม่ หากเป็นเช่นนั้น ระบบจะไม่ดำเนินการใดๆ
  2. โดยจะตั้งค่า Flag isSearchInProgress เป็น true เพื่อ "ล็อก" ฟังก์ชัน
  3. โดยจะแสดงวงกลมการโหลดและเตรียม UI สำหรับผลลัพธ์ใหม่
  4. โดยจะตั้งค่าพร็อพเพอร์ตี้ textQuery ของคำขอค้นหาเป็น null นี่เป็นขั้นตอนสำคัญที่บังคับให้คอมโพเนนต์เว็บรับรู้ว่ามีคำขอใหม่เข้ามา
  5. โดยใช้ setTimeout ที่มีความล่าช้า 0 เทคนิค JavaScript มาตรฐานนี้จะกำหนดเวลาให้โค้ดที่เหลือทำงานในงานของเบราว์เซอร์ถัดไป เพื่อให้มั่นใจว่าคอมโพเนนต์ได้ประมวลผลค่า null ก่อน แม้ว่าผู้ใช้จะค้นหาสิ่งเดียวกัน 2 ครั้ง ระบบก็จะทริกเกอร์การค้นหาใหม่เสมอ

เพิ่ม Listener เหตุการณ์

จากนั้นเราต้องเรียกใช้เมธอด performSearch เมื่อผู้ใช้โต้ตอบกับแอป เราจะสร้างเมธอดใหม่ attachEventListeners เพื่อเก็บโค้ดการจัดการเหตุการณ์ทั้งหมดไว้ในที่เดียว ตอนนี้เราจะเพิ่ม Listener สำหรับเหตุการณ์ 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. แสดงผลการค้นหาและเพิ่มเครื่องหมาย

ตอนนี้ทริกเกอร์การค้นหาใช้งานได้แล้ว งานต่อไปคือการแสดงผลลัพธ์บนหน้าจอ โค้ดในส่วนนี้จะเชื่อมต่อตรรกะการค้นหากับ UI เมื่อองค์ประกอบการค้นหาสถานที่โหลดข้อมูลเสร็จแล้ว องค์ประกอบจะยกเลิก "การล็อก" การค้นหา ซ่อนวงกลมหมุนที่กำลังโหลด และแสดงเครื่องหมายบนแผนที่สำหรับผลการค้นหาแต่ละรายการ

ฟังการค้นหาที่เสร็จสมบูรณ์

Place Search Element จะทริกเกอร์เหตุการณ์ gmp-load เมื่อดึงข้อมูลสำเร็จ ซึ่งเป็นสัญญาณที่เหมาะสมที่สุดสำหรับเราในการประมวลผลผลลัพธ์

ก่อนอื่น ให้เพิ่ม Listener เหตุการณ์สําหรับเหตุการณ์นี้ในเมธอด attachEventListeners

สร้างเมธอดการจัดการเครื่องหมาย

จากนั้นเราจะสร้างเมธอดตัวช่วยใหม่ 2 รายการ ได้แก่ clearMarkers และ addMarkers

  • clearMarkers() จะนำเครื่องหมายทั้งหมดจากการค้นหาก่อนหน้าออก
  • addMarkers() จะถูกเรียกใช้โดยgmp-load Listener โดยจะวนซ้ำในรายการสถานที่ที่ได้จากการค้นหาและสร้าง AdvancedMarkerElement ใหม่สำหรับแต่ละสถานที่ นอกจากนี้ เราจะซ่อนวงกลมการโหลดและปลดล็อก isSearchInProgress ในส่วนนี้ด้วย ซึ่งเป็นการสิ้นสุดวงจรการค้นหา

โปรดสังเกตว่าเราจัดเก็บเครื่องหมายในออบเจ็กต์ (this.markers) โดยใช้รหัสสถานที่เป็นคีย์ ซึ่งเป็นวิธีจัดการเครื่องหมายและจะช่วยให้เราค้นหาเครื่องหมายที่เฉพาะเจาะจงได้ในภายหลัง

สุดท้าย เราต้องเรียกใช้ clearMarkers() เมื่อเริ่มการค้นหาใหม่ทุกครั้ง ซึ่งคุณสามารถทำได้ใน performSearch

อัปเดตไฟล์ 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();

    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 ก่อนที่จะดำเนินการค้นหา

เพิ่ม Listener เหตุการณ์สำหรับตัวควบคุมทั้งหมด

จากนั้นเราจะขยายattachEventListeners เราจะเพิ่ม Listener สำหรับเหตุการณ์ change ในตัวควบคุมตัวกรองแต่ละรายการ รวมถึง Listener keydown ในช่องป้อนข้อมูลการค้นหาเพื่อตรวจหาเมื่อผู้ใช้กดปุ่ม "Enter" ผู้ฟังใหม่ทั้งหมดนี้จะเรียกใช้เมธอด performSearch

เชื่อมต่อรายการผลลัพธ์กับแผนที่

การคลิกรายการในรายการผลการค้นหาของแถบด้านข้างควรโฟกัสแผนที่ไปยังสถานที่นั้นๆ เพื่อสร้างประสบการณ์การใช้งานที่ราบรื่น

เมธอดใหม่ handleResultClick จะรอรับฟังเหตุการณ์ gmp-select ซึ่งจะเริ่มทำงานโดยองค์ประกอบการค้นหาสถานที่เมื่อมีการคลิกรายการ ฟังก์ชันนี้จะค้นหาตำแหน่งของสถานที่ที่เชื่อมโยงและเลื่อนแผนที่ไปยังตำแหน่งนั้นอย่างราบรื่น

หากต้องการให้ฟีเจอร์นี้ทำงานได้ โปรดตรวจสอบว่าแอตทริบิวต์ selectable อยู่ในคอมโพเนนต์ gmp-place-search ใน index.html

<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 Element ซึ่งจะปรากฏขึ้นเมื่อผู้ใช้คลิกเครื่องหมายบนแผนที่ หรือเลือกรายการใน Place Search Element

สร้างคอนเทนเนอร์การ์ดรายละเอียดที่นำกลับมาใช้ใหม่ได้

วิธีที่มีประสิทธิภาพมากที่สุดในการแสดงรายละเอียดสถานที่บนแผนที่คือการสร้างคอนเทนเนอร์เดียวที่นำกลับมาใช้ใหม่ได้ เราจะใช้ AdvancedMarkerElement เป็นคอนเทนเนอร์นี้ เนื้อหาของวิดเจ็ตนี้จะเป็นวิดเจ็ต gmp-place-details-compact ที่ซ่อนอยู่ซึ่งเรามีอยู่แล้วใน index.html

initDetailsPopup ซึ่งเป็นวิธีใหม่จะจัดการการสร้างเครื่องหมายที่ใช้ซ้ำได้นี้ ระบบจะสร้างครั้งเดียวเมื่อแอปพลิเคชันโหลดและจะเริ่มต้นโดยซ่อนไว้ นอกจากนี้ เราจะเพิ่มตัวฟังลงในแผนที่หลักด้วยวิธีนี้ เพื่อให้การคลิกที่ใดก็ได้บนแผนที่จะซ่อนการ์ดรายละเอียด

อัปเดตลักษณะการคลิกเครื่องหมาย

จากนั้นเราต้องอัปเดตสิ่งที่เกิดขึ้นเมื่อผู้ใช้คลิกเครื่องหมายสถานที่ ตอนนี้ 'click' listener ภายในเมธอด addMarkers จะมีหน้าที่แสดงการ์ดรายละเอียด

เมื่อมีการคลิกเครื่องหมาย ผู้ฟังจะทำสิ่งต่อไปนี้

  1. เลื่อนแผนที่ไปยังตำแหน่งของเครื่องหมาย
  2. อัปเดตการ์ดรายละเอียดด้วยข้อมูลของสถานที่นั้นๆ
  3. วางการ์ดรายละเอียดที่ตำแหน่งของเครื่องหมายและทำให้มองเห็นได้

เชื่อมต่อการคลิกลิสต์กับการคลิกเครื่องหมาย

สุดท้าย เราจะอัปเดตhandleResultClick แทนที่จะแค่เลื่อนแผนที่ ตอนนี้ระบบจะทริกเกอร์เหตุการณ์ click ในเครื่องหมายที่เกี่ยวข้องโดยอัตโนมัติ ซึ่งเป็นรูปแบบที่มีประสิทธิภาพที่ช่วยให้เรานำตรรกะเดียวกันมาใช้ซ้ำได้สำหรับการโต้ตอบทั้ง 2 แบบ ทำให้โค้ดของเราสะอาดและบำรุงรักษาได้

อัปเดตไฟล์ 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 ดูสะอาดตาและสอดคล้องกัน เราจะจัดรูปแบบคำค้นหาของผู้ใช้เป็น "Title Case" โดยอัตโนมัติ (เช่น "ร้านเบอร์เกอร์" จะกลายเป็น "ร้านเบอร์เกอร์") ฟังก์ชันตัวช่วย 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 เข้ากับประสิทธิภาพของ JavaScript API หลักของ Google Maps Platform

สิ่งที่คุณได้เรียนรู้

  • วิธีสร้างแอปพลิเคชันการแมปโดยใช้คลาส JavaScript เพื่อจัดการสถานะและตรรกะ
  • วิธีใช้ Places UI Kit กับ Google Maps JavaScript API เพื่อการพัฒนา UI อย่างรวดเร็ว
  • วิธีเพิ่มและจัดการเครื่องหมายขั้นสูงโดยอัตโนมัติเพื่อแสดงจุดที่น่าสนใจที่กำหนดเองบนแผนที่
  • วิธีใช้บริการ Geocoding เพื่อเปลี่ยนพิกัดเป็นที่อยู่ที่มนุษย์อ่านได้เพื่อประสบการณ์ของผู้ใช้ที่ดียิ่งขึ้น
  • วิธีระบุและแก้ไข Race Condition ทั่วไปในแอปพลิเคชันแบบอินเทอร์แอกทีฟโดยใช้แฟล็กสถานะและตรวจสอบว่าพร็อพเพอร์ตี้ของคอมโพเนนต์ได้รับการอัปเดตอย่างถูกต้อง

ขั้นตอนถัดไปคือ

คุณอยากเห็น Codelab อื่นๆ แบบไหน

การแสดงข้อมูลเป็นภาพบนแผนที่ ข้อมูลเพิ่มเติมเกี่ยวกับการปรับแต่งรูปแบบของแผนที่ การสร้างการโต้ตอบ 3 มิติในแผนที่

หากไม่พบโค้ดแล็บที่คุณสนใจมากที่สุด ขอได้โดยแจ้งปัญหาใหม่ที่นี่