إنشاء تطبيق بحث محلي باستخدام حزمة أدوات Places UI Kit

1. قبل البدء

يعلّمك هذا الدرس التطبيقي حول الترميز كيفية إنشاء تطبيق بحث محلي تفاعلي بالكامل باستخدام حزمة واجهة المستخدم الخاصة بخدمة "أماكن" في "منصة خرائط Google".

لقطة شاشة لتطبيق PlaceFinder المكتمل، تعرض خريطة لمدينة نيويورك مع علامات وشريطًا جانبيًا يتضمّن نتائج البحث وبطاقة تفاصيل مفتوحة.

المتطلبات الأساسية

  • مشروع على Google Cloud تم إعداد واجهات برمجة التطبيقات وبيانات الاعتماد اللازمة فيه
  • معرفة أساسية بلغتَي HTML وCSS
  • فهم لغة JavaScript الحديثة
  • متصفّح ويب حديث، مثل أحدث إصدار من Chrome
  • محرِّر نصوص من اختيارك

الإجراءات التي ستنفذّها

  • إنشاء تطبيق ربط باستخدام فئة JavaScript
  • استخدام "مكوّنات الويب" لعرض خريطة
  • استخدِم Place Search Element لتنفيذ نتائج "البحث النصي" وعرضها.
  • إنشاء علامات الخريطة المخصّصة AdvancedMarkerElement وإدارتها آليًا
  • عرض عنصر "تفاصيل المكان" عندما يختار المستخدم موقعًا جغرافيًا
  • استخدِم Geocoding API لإنشاء واجهة ديناميكية وسهلة الاستخدام.

المتطلبات

  • مشروع Google Cloud تم تفعيل الفوترة فيه
  • مفتاح 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، انقر على القائمة المنسدلة الخاصة بالمشروع واختَر المشروع الذي تريد استخدامه في هذا الدرس العملي.

  1. فعِّل واجهات برمجة التطبيقات وحِزم تطوير البرامج (SDK) في Google Maps Platform المطلوبة لهذا الدرس العملي في Google Cloud Marketplace. لإجراء ذلك، اتّبِع الخطوات الواردة في هذا الفيديو أو هذه المستندات.
  2. أنشئ مفتاح واجهة برمجة التطبيقات في صفحة بيانات الاعتماد في Cloud Console. يمكنك اتّباع الخطوات الواردة في هذا الفيديو أو هذه المستندات. تتطلّب جميع الطلبات إلى "منصة خرائط Google" مفتاح واجهة برمجة تطبيقات.

3- هيكل التطبيق وخريطة وظيفية

في هذه الخطوة الأولى، سننشئ التصميم المرئي الكامل لتطبيقنا ونضع بنية واضحة تستند إلى الفئات لرمز JavaScript. ويمنحنا هذا أساسًا متينًا يمكننا البناء عليه. في نهاية هذا القسم، سيكون لديك صفحة منمّقة تعرض خريطة تفاعلية.

إنشاء ملف HTML

أولاً، أنشِئ ملفًا باسم index.html. سيحتوي هذا الملف على البنية الكاملة لتطبيقنا، بما في ذلك العنوان وفلاتر البحث والشريط الجانبي وحاوية الخريطة ومكوّنات الويب اللازمة.

انسخ الرمز التالي في index.html. احرص على استبدال YOUR_API_KEY_HERE بمفتاح واجهة برمجة التطبيقات الخاص بك على "منصة خرائط 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();
});

القيود المفروضة على مفتاح واجهة برمجة التطبيقات

قد تحتاج إلى إضافة قيد جديد إلى مفتاح واجهة برمجة التطبيقات لكي يعمل هذا الدرس العملي. اطّلِع على تقييد مفاتيح واجهة برمجة التطبيقات للحصول على مزيد من المعلومات والإرشادات حول كيفية إجراء ذلك.

التحقّق من عملك

افتح ملف index.html في متصفّح الويب. من المفترض أن تظهر لك صفحة تحتوي على عنوان يتضمّن شريط بحث وفلاتر، وشريط جانبي يتضمّن الرسالة "ستظهر نتائج البحث هنا"، وخريطة كبيرة في وسطها مدينة نيويورك. في هذه المرحلة، لا تعمل عناصر التحكّم في البحث بعد.

4. تنفيذ وظيفة البحث

في هذا القسم، سننشئ تطبيقنا من خلال تنفيذ وظيفة البحث الأساسية. سنكتب الرمز الذي يتم تنفيذه عندما ينقر المستخدم على الزر "بحث". سننشئ هذه الوظيفة باتّباع أفضل الممارسات منذ البداية للتعامل مع تفاعلات المستخدمين بسلاسة وتجنُّب الأخطاء الشائعة، مثل أخطاء التزامن.

في نهاية هذه الخطوة، ستتمكّن من النقر على زر البحث وستظهر لك أداة تحميل أثناء جلب التطبيق للبيانات في الخلفية.

إنشاء طريقة البحث

أولاً، حدِّد طريقة performSearch داخل فئة PlaceFinderApp. ستكون هذه الدالة أساس منطق البحث. سنقدّم أيضًا متغيّرًا للمثيل، isSearchInProgress، ليعمل كـ "حارس بوابة". يمنع هذا الإجراء المستخدم من بدء بحث جديد أثناء تقدّم بحث آخر، ما قد يؤدي إلى حدوث أخطاء.

قد تبدو القواعد المنطقية داخل performSearch معقّدة، لذا سنشرحها بالتفصيل:

  1. يتحقّق أولاً ممّا إذا كان البحث قيد التقدّم. إذا كان الأمر كذلك، لن يتم اتّخاذ أي إجراء.
  2. يضبط هذا الإعداد العلامة isSearchInProgress على true من أجل "قفل" الدالة.
  3. يعرض هذا الرمز دائرة التحميل ويجهّز واجهة المستخدم لعرض نتائج جديدة.
  4. يضبط هذا الحقل قيمة السمة textQuery في طلب البحث على null. هذه خطوة مهمة تجبر مكوّن الويب على التعرّف على أنّ هناك طلبًا جديدًا واردًا.
  5. يستخدم setTimeout مع تأخير 0. تؤدي تقنية JavaScript العادية هذه إلى جدولة بقية الرمز البرمجي ليتم تنفيذه في مهمة المتصفّح التالية، ما يضمن معالجة قيمة null أولاً. حتى إذا بحث المستخدم عن الشيء نفسه مرتين، سيتم دائمًا بدء بحث جديد.

إضافة أدوات معالجة الأحداث

بعد ذلك، علينا استدعاء طريقة performSearch عندما يتفاعل المستخدم مع التطبيق. سننشئ طريقة جديدة، attachEventListeners، للاحتفاظ بكل رمز معالجة الأحداث في مكان واحد. في الوقت الحالي، سنضيف فقط أداة معالجة لحدث click الخاص بزر البحث. سنضيف أيضًا عنصرًا نائبًا لحدث آخر، وهو gmp-load، وسنستخدمه في الخطوة التالية.

تعديل ملف JavaScript

عدِّل ملف script.js باستخدام الرمز التالي. القسمان الجديدان أو المعدَّلان هما طريقة attachEventListeners وطريقة performSearch.

// script.js
class PlaceFinderApp {
  constructor() {
    // Get all DOM element references
    this.queryInput = document.getElementById('query-input');
    this.priceFilter = document.getElementById('price-filter');
    this.ratingFilter = document.getElementById('rating-filter');
    this.openNowFilter = document.getElementById('open-now-filter');
    this.searchButton = document.getElementById('search-button');
    this.placeSearch = document.getElementById('place-search-list');
    this.gMap = document.querySelector('gmp-map');
    this.loadingSpinner = document.getElementById('loading-spinner');
    this.resultsHeaderText = document.getElementById('results-header-text');
    this.placeholderMessage = document.getElementById('placeholder-message');
    this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
    this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
    this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');

    // Initialize instance variables
    this.map = null;
    this.geocoder = null;
    this.markers = {};
    this.detailsPopup = null;
    this.PriceLevel = null;
    this.isSearchInProgress = false;

    // Start the application
    this.init();
  }

  async init() {
    // Import libraries
    await google.maps.importLibrary("maps");
    const { Place, PriceLevel } = await google.maps.importLibrary("places");
    const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
    const { Geocoder } = await google.maps.importLibrary("geocoding");

    // Make classes available to the instance
    this.PriceLevel = PriceLevel;
    this.AdvancedMarkerElement = AdvancedMarkerElement;

    this.map = this.gMap.innerMap;
    this.geocoder = new Geocoder();

    // Call the new method to set up listeners
    this.attachEventListeners();
  }

  // NEW: Method to set up all event listeners
  attachEventListeners() {
    this.searchButton.addEventListener('click', this.performSearch.bind(this));
    // We will add the gmp-load listener in the next step
  }

  // NEW: Core search method
  async performSearch() {
    // Exit if a search is already in progress
    if (this.isSearchInProgress) {
      return;
    }
    // Set the lock
    this.isSearchInProgress = true;

    // Show the placeholder and spinner
    this.placeholderMessage.classList.add('hidden');
    this.placeSearch.classList.remove('hidden');
    this.showLoading(true);

    // Force a state change by clearing the query first.
    this.searchRequest.textQuery = null;

    // Defer setting the real properties to the next event loop cycle.
    setTimeout(async () => {
      const rawQuery = this.queryInput.value.trim();
      // If the query is empty, release the lock and hide the spinner
      if (!rawQuery) {
          this.showLoading(false);
          this.isSearchInProgress = false;
          return;
      };

      // For now, we just set the textQuery. We'll add filters later.
      this.searchRequest.textQuery = rawQuery;
      this.searchRequest.locationRestriction = this.map.getBounds();
    }, 0);
  }

  // NEW: Helper method to show/hide the spinner
  showLoading(visible) {
    this.loadingSpinner.classList.toggle('visible', visible);
  }
}

// Wait for the DOM to be ready, then create an instance of our app.
window.addEventListener('DOMContentLoaded', () => {
  new PlaceFinderApp();
});

التحقّق من عملك

احفظ ملف script.js وأعِد تحميل index.html في المتصفّح. يجب أن تبدو الصفحة كما كانت من قبل. الآن، انقر على الزر "بحث" في رأس الصفحة.

من المفترض أن يحدث أمران:

  1. تختفي الرسالة النائبة "ستظهر نتائج البحث هنا".
  2. يظهر مؤشر سريان التحميل ويستمر في الدوران.

سيستمر مؤشر التحميل في الدوران إلى الأبد لأنّنا لم نحدّد بعد متى يجب أن يتوقف. سنفعل ذلك في القسم التالي عندما نعرض النتائج. يؤكّد ذلك أنّه يتم تشغيل وظيفة البحث بشكلٍ صحيح.

5- عرض النتائج وإضافة العلامات

بعد أن أصبح مشغّل البحث يعمل، تتمثل المهمة التالية في عرض النتائج على الشاشة. سيربط الرمز البرمجي في هذا القسم منطق البحث بواجهة المستخدم. بعد أن ينتهي عنصر "البحث عن أماكن" من تحميل البيانات، سيتم إلغاء "قفل" البحث وإخفاء مؤشر التحميل الدوّار وعرض علامة على الخريطة لكل نتيجة.

الاستماع إلى إكمال البحث

يُطلق عنصر "بحث الأماكن" الحدث gmp-load عند استرداد البيانات بنجاح. هذه هي الإشارة المثالية التي يمكننا من خلالها معالجة النتائج.

أولاً، أضِف أداة معالجة الأحداث لهذا الحدث في الطريقة attachEventListeners.

إنشاء طرق للتعامل مع العلامات

بعد ذلك، سننشئ طريقتَين جديدتَين للمساعدة: clearMarkers وaddMarkers.

  • سيؤدي النقر على clearMarkers() إلى إزالة أي علامات من عملية بحث سابقة.
  • سيتلقّى الرقم addMarkers() مكالمة من gmp-load. سيتم تكرار قائمة الأماكن التي تم إرجاعها من خلال البحث وإنشاء AdvancedMarkerElement جديد لكل مكان. وهنا أيضًا سنخفي مؤشر التحميل ونزيل قفل isSearchInProgress، ما يؤدي إلى إكمال دورة البحث.

لاحظ أنّنا نخزّن العلامات في عنصر (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 قبل تنفيذ البحث.

إضافة أدوات معالجة الأحداث لجميع عناصر التحكّم

بعد ذلك، سنوسّع طريقة attachEventListeners. سنضيف أدوات معالجة الحدث change إلى كل عنصر تحكّم في الفلتر، بالإضافة إلى أداة معالجة الحدث 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. تنفيذ عنصر "تفاصيل المكان"

أصبح تطبيقنا الآن تفاعليًا بالكامل، ولكنّه يفتقر إلى ميزة أساسية، وهي إمكانية الاطّلاع على مزيد من المعلومات حول مكان محدّد. في هذا القسم، سننفّذ "عنصر تفاصيل المكان" الذي سيظهر عندما ينقر المستخدم على علامة على الخريطة أو يختار عنصرًا في "عنصر البحث عن الأماكن".

إنشاء حاوية بطاقة تفاصيل قابلة لإعادة الاستخدام

إنّ الطريقة الأكثر فعالية لعرض تفاصيل المكان على الخريطة هي إنشاء حاوية واحدة قابلة لإعادة الاستخدام. سنستخدم AdvancedMarkerElement كحاوية. سيكون محتوى هذا العنصر هو الأداة المخفية gmp-place-details-compact التي لدينا حاليًا في index.html.

ستتولّى طريقة جديدة، initDetailsPopup، إنشاء هذا العلامة القابلة لإعادة الاستخدام. سيتم إنشاؤه مرة واحدة عند تحميل التطبيق وسيبدأ مخفيًا. سنضيف أيضًا أداة معالجة إلى الخريطة الرئيسية في هذه الطريقة، حتى يؤدي النقر على أي مكان على الخريطة إلى إخفاء بطاقة التفاصيل.

تعديل سلوك النقر على العلامة

بعد ذلك، علينا تعديل ما يحدث عندما ينقر المستخدم على علامة مكان. سيكون معالج الأحداث 'click' داخل الطريقة addMarkers مسؤولاً الآن عن عرض بطاقة التفاصيل.

عند النقر على علامة، سيقوم المستمع بما يلي:

  1. حرِّك الخريطة إلى موقع العلامة.
  2. عدِّل بطاقة التفاصيل باستخدام المعلومات الخاصة بهذا المكان.
  3. ضَع بطاقة التفاصيل في موقع العلامة واجعلها مرئية.

ربط النقر على القائمة بالنقر على العلامة

أخيرًا، سنعدّل الطريقة handleResultClick. بدلاً من تحريك الخريطة فقط، سيؤدي ذلك الآن إلى تفعيل الحدث click بشكل آلي على العلامة المقابلة. هذا نمط قوي يتيح لنا إعادة استخدام المنطق نفسه تمامًا في كلا التفاعلين، ما يحافظ على نظافة التعليمات البرمجية وسهولة صيانتها.

تعديل ملف JavaScript

عدِّل ملف script.js باستخدام الرمز التالي. الأقسام الجديدة أو المعدَّلة هي طريقة initDetailsPopup وطريقتَا addMarkers وhandleResultClick المعدَّلتان.

// script.js
class PlaceFinderApp {
  constructor() {
    // Get all DOM element references
    this.queryInput = document.getElementById('query-input');
    this.priceFilter = document.getElementById('price-filter');
    this.ratingFilter = document.getElementById('rating-filter');
    this.openNowFilter = document.getElementById('open-now-filter');
    this.searchButton = document.getElementById('search-button');
    this.placeSearch = document.getElementById('place-search-list');
    this.gMap = document.querySelector('gmp-map');
    this.loadingSpinner = document.getElementById('loading-spinner');
    this.resultsHeaderText = document.getElementById('results-header-text');
    this.placeholderMessage = document.getElementById('placeholder-message');
    this.placeDetailsWidget = document.querySelector('gmp-place-details-compact');
    this.placeDetailsRequest = this.placeDetailsWidget.querySelector('gmp-place-details-place-request');
    this.searchRequest = this.placeSearch.querySelector('gmp-place-text-search-request');

    // Initialize instance variables
    this.map = null;
    this.geocoder = null;
    this.markers = {};
    this.detailsPopup = null;
    this.PriceLevel = null;
    this.isSearchInProgress = false;

    // Start the application
    this.init();
  }

  async init() {
    // Import libraries
    await google.maps.importLibrary("maps");
    const { Place, PriceLevel } = await google.maps.importLibrary("places");
    const { AdvancedMarkerElement } = await google.maps.importLibrary("marker");
    const { Geocoder } = await google.maps.importLibrary("geocoding");

    // Make classes available to the instance
    this.PriceLevel = PriceLevel;
    this.AdvancedMarkerElement = AdvancedMarkerElement;

    this.map = this.gMap.innerMap;
    this.geocoder = new Geocoder();

    // NEW: Call the method to initialize the details card
    this.initDetailsPopup();
    this.attachEventListeners();
  }

  attachEventListeners() {
    this.queryInput.addEventListener('keydown', (event) => {
      if (event.key === 'Enter') {
        event.preventDefault();
        this.performSearch();
      }
    });
    this.placeSearch.addEventListener('gmp-select', this.handleResultClick.bind(this));
    this.placeSearch.addEventListener('gmp-load', this.addMarkers.bind(this));
    this.searchButton.addEventListener('click', this.performSearch.bind(this));
    this.priceFilter.addEventListener('change', this.performSearch.bind(this));
    this.ratingFilter.addEventListener('change', this.performSearch.bind(this));
    this.openNowFilter.addEventListener('change', this.performSearch.bind(this));
  }

  // NEW: Method to set up the reusable details card
  initDetailsPopup() {
    this.detailsPopup = new this.AdvancedMarkerElement({
      content: this.placeDetailsWidget,
      map: null,
      zIndex: 100
    });
    this.map.addListener('click', () => { this.detailsPopup.map = null; });
  }

  clearMarkers() {
    for (const marker of Object.values(this.markers)) {
      marker.map = null;
    }
    this.markers = {};
  }

  // UPDATED: The marker's click listener now shows the details card
  addMarkers() {
    this.isSearchInProgress = false;
    this.showLoading(false);

    const places = this.placeSearch.places;
    if (!places || places.length === 0) return;

    for (const place of places) {
      if (!place.location || !place.id) continue;
      const marker = new this.AdvancedMarkerElement({
        map: this.map,
        position: place.location,
        title: place.displayName,
      });
      // Add the click listener to show the details card
      marker.addListener('click', (event) => {
        event.stop();
        this.map.panTo(place.location);
        this.placeDetailsRequest.place = place;
        this.placeDetailsWidget.style.display = 'block';
        this.detailsPopup.position = place.location;
        this.detailsPopup.map = this.map;
      });
      this.markers[place.id] = marker;
    }
  }

  // UPDATED: This now triggers the marker's click event
  handleResultClick(event) {
    const place = event.place;
    if (!place || !place.id) return;
    const marker = this.markers[place.id];
    if (marker) {
      // Programmatically trigger the marker's click event
      marker.click();
    }
  }

  async performSearch() {
    if (this.isSearchInProgress) return;
    this.isSearchInProgress = true;
    this.placeholderMessage.classList.add('hidden');
    this.placeSearch.classList.remove('hidden');
    this.showLoading(true);
    this.clearMarkers();
    // Hide the details card when a new search starts
    if (this.detailsPopup) this.detailsPopup.map = null;

    this.searchRequest.textQuery = null;

    setTimeout(async () => {
      const rawQuery = this.queryInput.value.trim();
      if (!rawQuery) {
          this.showLoading(false);
          this.isSearchInProgress = false;
          return;
      };

      this.searchRequest.textQuery = rawQuery;
      this.searchRequest.locationRestriction = this.map.getBounds();

      const selectedPrice = this.priceFilter.value;
      let priceLevels = [];
      switch (selectedPrice) {
        case "1": priceLevels = [this.PriceLevel.INEXPENSIVE]; break;
        case "2": priceLevels = [this.PriceLevel.MODERATE]; break;
        case "3": priceLevels = [this.PriceLevel.EXPENSIVE]; break;
        case "4": priceLevels = [this.PriceLevel.VERY_EXPENSIVE]; break;
        default: priceLevels = null; break;
      }
      this.searchRequest.priceLevels = priceLevels;

      const selectedRating = parseFloat(this.ratingFilter.value);
      this.searchRequest.minRating = selectedRating > 0 ? selectedRating : null;
      this.searchRequest.isOpenNow = this.openNowFilter.checked ? true : null;
    }, 0);
  }

  showLoading(visible) {
    this.loadingSpinner.classList.toggle('visible', visible);
  }
}

window.addEventListener('DOMContentLoaded', () => {
  new PlaceFinderApp();
});

التحقّق من عملك

احفظ ملف script.js وأعِد تحميل الصفحة. من المفترض أن يعرض التطبيق الآن التفاصيل عند الطلب.

تحقَّق مما يلي:

  • عند النقر على علامة على الخريطة، يتم الآن توسيط الخريطة وفتح بطاقة تفاصيل منمّقة فوق العلامة.
  • ويؤدي النقر على عنصر في قائمة النتائج في الشريط الجانبي إلى تنفيذ الإجراء نفسه.
  • يؤدي النقر على الخريطة بعيدًا عن البطاقة إلى إغلاقها.
  • يؤدي بدء بحث جديد أيضًا إلى إغلاق أي بطاقة تفاصيل مفتوحة.

8. إضافة اللمسات الأخيرة

تطبيقنا يعمل الآن بكامل وظائفه، ولكن هناك بعض اللمسات النهائية التي يمكننا إضافتها لتحسين تجربة المستخدم بشكلٍ أكبر. في هذا القسم الأخير، سننفّذ ميزتَين أساسيتَين: عنوان ديناميكي يوفّر سياقًا أفضل لنتائج البحث، وتنسيق تلقائي لطلب بحث المستخدم.

إنشاء عنوان ديناميكي للنتائج

في الوقت الحالي، يظهر العنوان "النتائج" دائمًا في أعلى الشريط الجانبي. يمكننا جعل هذه الرسالة أكثر إفادةً من خلال تعديلها لتعكس البحث الحالي. على سبيل المثال، "برغر بالقرب من القاهرة".

لإجراء ذلك، سنستخدم Geocoding API لتحويل إحداثيات مركز الخريطة إلى موقع جغرافي يمكن قراءته، مثل اسم مدينة. ستتعامل طريقة async الجديدة، updateResultsHeader، مع هذه المنطق. سيتم استدعاؤها في كل مرة يتم فيها إجراء بحث.

تنسيق طلب البحث الذي أدخله المستخدم

للتأكّد من أنّ واجهة المستخدم تبدو منظَّمة ومتسقة، سننسّق تلقائيًا عبارة البحث التي أدخلها المستخدم لتصبح "حالة العنوان" (مثل يصبح "مطعم همبرغر" "مطعم همبرغر"). ستتولّى دالة مساعِدة، 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 على &quot;منصة خرائط Google&quot;.

ما تعلّمته

  • كيفية إنشاء تطبيق خرائط باستخدام فئة JavaScript لإدارة الحالة والمنطق
  • كيفية استخدام حزمة أدوات Places UI Kit مع Google Maps JavaScript API لتطوير واجهة المستخدم بسرعة
  • كيفية إضافة العلامات المتقدّمة وإدارتها آليًا لعرض نقاط الاهتمام المخصّصة على الخريطة
  • كيفية استخدام خدمة الترميز الجغرافي لتحويل الإحداثيات إلى عناوين يمكن قراءتها من أجل تحسين تجربة المستخدم
  • كيفية تحديد المشاكل الشائعة المتعلقة بتزامن العمليات وإصلاحها في تطبيق تفاعلي باستخدام علامات الحالة والتأكّد من تعديل خصائص المكوّن بشكل صحيح

ما هي الخطوات التالية؟

ما هي جلسات الترميز الأخرى التي تريد المشاركة فيها؟

العرض المرئي للبيانات على الخرائط مزيد من المعلومات عن تخصيص نمط خرائطي إنشاء تفاعلات ثلاثية الأبعاد في الخرائط

هل يتعذّر عليك العثور على الدرس العملي الذي يهمّك أكثر؟ يمكنك طلب ذلك من خلال تقديم مشكلة جديدة هنا.