פיתוח אפליקציית חיפוש מקומית באמצעות Places UI Kit

1. לפני שמתחילים

ב-codelab הזה נלמד איך ליצור אפליקציה אינטראקטיבית לחלוטין לחיפוש מקומי באמצעות ערכת ממשק המשתמש של Places בפלטפורמה של מפות Google.

צילום מסך של אפליקציית PlaceFinder המוגמרת, שבו מוצגת מפה של ניו יורק עם סמנים, סרגל צד עם תוצאות חיפוש וכרטיס פרטים פתוח.

דרישות מוקדמות

  • פרויקט ב-Google Cloud עם ממשקי ה-API ופרטי הכניסה הנדרשים.
  • ידע בסיסי ב-HTML וב-CSS.
  • הבנה של JavaScript בשיטה מודרנית.
  • דפדפן אינטרנט מודרני, כמו הגרסה האחרונה של Chrome.
  • כלי לעריכת טקסט לפי בחירתכם.

הפעולות שתבצעו:

  • איך יוצרים אפליקציית מיפוי באמצעות מחלקה ב-JavaScript.
  • שימוש ברכיבי אינטרנט להצגת מפה
  • שימוש ברכיב Place Search כדי לבצע חיפוש טקסט ולהציג את התוצאות.
  • יצירה וניהול של סמני מפה מותאמים אישית AdvancedMarkerElement באופן פרוגרמטי.
  • הצגת רכיב פרטי המקום כשמשתמש בוחר מיקום.
  • אפשר להשתמש ב-Geocoding API כדי ליצור ממשק דינמי וידידותי למשתמש.

מה נדרש

  • פרויקט ב-Google Cloud שהחיוב בו מופעל
  • מפתח Google Maps Platform API
  • מזהה מפה
  • ממשקי ה-API הבאים הופעלו:
    • Maps JavaScript API
    • Places UI Kit
    • Geocoding API

2. להגדרה

בשלב הבא של ההפעלה, תצטרכו להפעיל את Maps JavaScript API, את Places UI Kit ואת Geocoding API.

הגדרת הפלטפורמה של מפות Google

אם עדיין אין לכם חשבון ב-Google Cloud Platform ופרויקט עם חיוב מופעל, תוכלו לעיין במדריך תחילת העבודה עם הפלטפורמה של מפות Google כדי ליצור חשבון לחיוב ופרויקט.

  1. בCloud Console, לוחצים על התפריט הנפתח של הפרויקט ובוחרים את הפרויקט שבו רוצים להשתמש ב-codelab הזה.

  1. מפעילים ב-Google Cloud Marketplace את ממשקי ה-API וערכות ה-SDK של הפלטפורמה של מפות Google שנדרשים ל-codelab הזה. כדי לעשות זאת, פועלים לפי השלבים בסרטון הזה או בתיעוד הזה.
  2. יוצרים מפתח API בדף Credentials במסוף Cloud. אפשר לפעול לפי השלבים שמפורטים בסרטון הזה או בתיעוד הזה. כל הבקשות אל הפלטפורמה של מפות Google מחייבות מפתח API.

3. מעטפת האפליקציה ומפה פונקציונלית

בשלב הראשון הזה, ניצור את הפריסה החזותית המלאה של האפליקציה שלנו ונקבע מבנה נקי שמבוסס על מחלקות עבור ה-JavaScript שלנו. זה נותן לנו בסיס איתן להמשך. בסוף הקטע הזה, יהיה לכם דף עם סגנון שכולל מפה אינטראקטיבית.

יצירת קובץ ה-HTML

קודם יוצרים קובץ בשם index.html. הקובץ הזה יכיל את המבנה המלא של האפליקציה שלנו, כולל הכותרת, מסנני החיפוש, סרגל הצד, מאגר המפות ורכיבי האינטרנט הנדרשים.

מעתיקים את הקוד הבא אל index.html. חשוב להחליף את YOUR_API_KEY_HERE במפתח ה-API שלכם בפלטפורמה של מפות Google, ואת DEMO_MAP_ID במזהה המפה שלכם בפלטפורמה של מפות Google.

<!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. נבנה את האפליקציה שלנו בתוך מחלקת JavaScript שנקראת PlaceFinderApp. כך הקוד שלנו מאורגן והמצב מנוהל בצורה נקייה.

הקוד הראשוני הזה יגדיר את המחלקה, ימצא את כל רכיבי ה-HTML ב-constructor וייצור שיטה init() לטעינת הספריות של הפלטפורמה של מפות Google.

מעתיקים את הקוד הבא אל 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. היא מגדירה את הדגל isSearchInProgress לערך true כדי 'לנעול' את הפונקציה.
  3. מוצג בו סמל הטעינה וממשק המשתמש מוכן לתוצאות חדשות.
  4. הפונקציה קובעת שהמאפיין textQuery של בקשת החיפוש יהיה null. זהו שלב קריטי שגורם לרכיב האינטרנט לזהות שבקשה חדשה מגיעה.
  5. היא משתמשת בsetTimeout עם עיכוב של 0. הטכניקה הסטנדרטית הזו של JavaScript מתזמנת את שאר הקוד שלנו להרצה במשימה הבאה של הדפדפן, כדי לוודא שהרכיב עיבד קודם את הערך null. גם אם המשתמש יחפש את אותו הדבר בדיוק פעמיים, תמיד יופעל חיפוש חדש.

הוספת פונקציות event listener

בשלב הבא, צריך לקרוא לשיטה performSearch כשמשתמשים מקיימים אינטראקציה עם האפליקציה. ניצור שיטה חדשה, attachEventListeners, כדי לשמור את כל קוד הטיפול באירועים במקום אחד. בשלב הזה, נוסיף listener לאירוע click של לחצן החיפוש. נוסיף גם placeholder לאירוע נוסף, 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. ההודעה של placeholder 'תוצאות החיפוש יופיעו כאן' נעלמת.
  2. סימן הטעינה מופיע וממשיך להסתובב.

הסמל של הגלגל יסתובב לנצח כי עדיין לא אמרנו לו מתי להפסיק. נעשה זאת בקטע הבא כשנציג את התוצאות. כך אפשר לוודא שפונקציית החיפוש מופעלת בצורה נכונה.

5. הצגת התוצאות והוספת סמנים

אחרי שהפעלנו את טריגר החיפוש, השלב הבא הוא להציג את התוצאות במסך. הקוד בקטע הזה יקשר את לוגיקת החיפוש לממשק המשתמש. אחרי שרכיב חיפוש המקומות יסיים לטעון את הנתונים, הוא יבטל את ה "נעילה" של החיפוש, יסתיר את סמל הטעינה ויציג סמן במפה לכל תוצאה.

האזנה להשלמה אוטומטית בחיפוש

האלמנט Place Search מפעיל אירוע gmp-load כשהוא מאחזר נתונים בהצלחה. זה האות המושלם בשבילנו לעיבוד התוצאות.

קודם כול, מוסיפים event listener לאירוע הזה בשיטה attachEventListeners.

יצירת שיטות לטיפול בסמנים

בשלב הבא ניצור שתי שיטות עזר חדשות: clearMarkers ו-addMarkers.

  • clearMarkers() יסיר את כל הסמנים מחיפוש קודם.
  • המאזין gmp-load שלנו יתקשר אל addMarkers(). הפונקציה תעבור על רשימת המקומות שיוחזרו מהחיפוש ותיצור 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 לפני שהחיפוש יופעל.

הוספת פונקציות event listener לכל אמצעי הבקרה

בשלב הבא נרחיב את השיטה שלנו attachEventListeners. נוסיף listeners לאירוע change בכל אמצעי בקרה של המסנן, וגם listener ל-keydown בקלט החיפוש כדי לזהות מתי המשתמש לוחץ על מקש Enter. כל המאזינים החדשים יקראו ל-method‏ 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. הטמעה של רכיב פרטי המקום

האפליקציה שלנו אינטראקטיבית לחלוטין, אבל חסרה בה תכונה חשובה: האפשרות לראות מידע נוסף על מקום שנבחר. בקטע הזה נטמיע את רכיב פרטי המקום שיופיע כשמשתמש ילחץ על סמן במפה או יבחר פריט ברכיב חיפוש המקומות.

יצירת קונטיינר לכרטיס פרטים לשימוש חוזר

הדרך היעילה ביותר להציג פרטים של מקום במפה היא ליצור מאגר יחיד שאפשר להשתמש בו שוב. נשתמש ב-AdvancedMarkerElement כמאגר הזה. התוכן שלו יהיה הווידג'ט המוסתר gmp-place-details-compact שכבר קיים ב-index.html שלנו.

שיטה חדשה, initDetailsPopup, תטפל ביצירה של הסמן הזה לשימוש חוזר. הוא ייווצר פעם אחת כשהאפליקציה תיטען, והוא יתחיל במצב מוסתר. בשיטה הזו נוסיף גם מאזין למפה הראשית, כך שלחיצה על מקום כלשהו במפה תסתיר את כרטיס הפרטים.

עדכון התנהגות הקליקים על הסמן

בשלב הבא, צריך לעדכן את הפעולה שמתבצעת כשמשתמש לוחץ על סמן של מקום. מעכשיו, מאזין 'click' בתוך שיטת addMarkers יהיה אחראי להצגת כרטיס הפרטים.

כשלוחצים על סמן, מאזין האירועים:

  1. מזיזים את המפה למיקום של הסמן.
  2. מעדכנים את כרטיס הפרטים במידע על המקום הספציפי.
  3. ממקמים את כרטיס הפרטים במיקום הסמן ומוודאים שהוא גלוי.

קישור הקליק על הרשימה לקליק על הסמן

לבסוף, נעדכן את השיטה handleResultClick. במקום רק להזיז את המפה, עכשיו יופעל באופן אוטומטי האירוע click בסמן המתאים. זוהי תבנית יעילה שמאפשרת לנו לעשות שימוש חוזר באותה לוגיקה בדיוק בשני סוגי האינטראקציות, וכך לשמור על קוד נקי וקל לתחזוקה.

עדכון קובץ JavaScript

מעדכנים את הקובץ script.js עם הקוד הבא. החלקים החדשים או ששונו הם initDetailsPopup method והשיטות המעודכנות 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. הוספת הגימור הסופי

האפליקציה שלנו מתפקדת עכשיו באופן מלא, אבל יש כמה שיפורים קטנים שאפשר להוסיף כדי לשפר עוד יותר את חוויית המשתמש. בקטע האחרון הזה, נטמיע שתי תכונות מרכזיות: כותרת דינמית שמספקת הקשר טוב יותר לתוצאות החיפוש, ועיצוב אוטומטי של שאילתת החיפוש של המשתמש.

יצירת כותרת דינמית לתוצאות

בשלב הזה, הכותרת בסרגל הצד היא תמיד 'תוצאות'. כדי שהמידע יהיה יותר מועיל, אנחנו יכולים לעדכן אותו כך שישקף את החיפוש הנוכחי. לדוגמה, "המבורגרים ליד ניו יורק".

לשם כך, נשתמש ב-Geocoding API כדי להמיר את קואורדינטות המרכז של המפה למיקום שניתן לקריאה על ידי בני אדם, כמו שם של עיר. הלוגיקה הזו תטופל על ידי שיטה חדשה, async, בשם updateResultsHeader. הפונקציה הזו תופעל בכל פעם שמתבצע חיפוש.

הגדרת הפורמט של שאילתת החיפוש של המשתמש

כדי לוודא שממשק המשתמש נראה נקי ועקבי, אנחנו מעצבים אוטומטית את מונח החיפוש של המשתמש בפורמט Title Case (למשל, ‫"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 עם העוצמה של ממשקי ה-API העיקריים של JavaScript בפלטפורמה של מפות Google.

מה למדתם

  • איך לבנות אפליקציית מיפוי באמצעות מחלקת JavaScript לניהול מצב ולוגיקה.
  • איך משתמשים ב-Places UI Kit עם Google Maps JavaScript API לפיתוח מהיר של ממשק משתמש.
  • איך להוסיף ולנהל סמנים מתקדמים באופן פרוגרמטי כדי להציג נקודות עניין מותאמות אישית במפה.
  • איך משתמשים בGeocoding Service כדי להמיר קואורדינטות לכתובות שקל לקרוא, וכך לשפר את חוויית המשתמש.
  • איך לזהות ולתקן תנאי מירוץ נפוצים באפליקציה אינטראקטיבית באמצעות דגלי מצב, ולוודא שהמאפיינים של הרכיבים מתעדכנים בצורה נכונה.

מה השלב הבא?

  • מידע נוסף על התאמה אישית של סמנים מתקדמים על ידי שינוי הצבע, קנה המידה או אפילו שימוש ב-HTML מותאם אישית
  • כדאי לעיין במאמר בנושא עיצוב מפות מבוסס-ענן כדי להתאים אישית את המראה והתחושה של המפה כך שיתאימו למותג שלכם.
  • אפשר לנסות להוסיף את Drawing Library כדי לאפשר למשתמשים לצייר צורות במפה ולהגדיר אזורי חיפוש.
  • כדי לעזור לנו ליצור את התוכן שהכי יעזור לך, נבקש ממך למלא את הסקר הבא:

אילו codelabs נוספים היית רוצה לראות?

הצגה חזותית של נתונים במפות מידע נוסף על התאמה אישית של סגנון המפות יצירת אינטראקציות תלת-ממדיות במפות

לא מוצאים את ה-codelab שהכי מעניין אתכם? כאן אפשר לשלוח בקשה בנושא בעיה חדשה.