Crea una app de búsqueda local con el kit de IU de Places

1. Antes de comenzar

En este codelab, aprenderás a compilar una aplicación de búsqueda local completamente interactiva con el kit de IU de Places de Google Maps Platform.

Una captura de pantalla de la aplicación PlaceFinder terminada, que muestra un mapa de Nueva York con marcadores, una barra lateral con resultados de la búsqueda y una tarjeta de detalles abierta.

Requisitos previos

  • Un proyecto de Google Cloud con las APIs y las credenciales necesarias configuradas
  • Conocimientos básicos de HTML y CSS
  • Conocimiento de JavaScript moderno
  • Un navegador web moderno, como la versión más reciente de Chrome
  • El editor de texto que prefieras

Actividades

  • Estructurar una aplicación de mapas con una clase de JavaScript
  • Cómo usar componentes web para mostrar un mapa
  • Usa el elemento Place Search para realizar y mostrar los resultados de una búsqueda de texto.
  • Crea y administra de forma programática marcadores de mapa AdvancedMarkerElement personalizados.
  • Mostrar el elemento Place Details cuando un usuario selecciona una ubicación
  • Usa la API de Geocoding para crear una interfaz dinámica y fácil de usar.

Requisitos

  • Un proyecto de Google Cloud con la facturación habilitada.
  • Una clave de API de Google Maps Platform
  • Un ID de mapa
  • Se habilitaron las siguientes APIs:
    • API de Maps JavaScript
    • Kit de IU de Places
    • API de Geocoding

2. Prepárate

Para el siguiente paso, deberás habilitar la API de Maps JavaScript, el kit de IU de Places y la API de Geocoding.

Configura Google Maps Platform

Si todavía no tienes una cuenta de Google Cloud Platform y un proyecto con la facturación habilitada, consulta la guía Cómo comenzar a utilizar Google Maps Platform para crear una cuenta de facturación y un proyecto.

  1. En Cloud Console, haz clic en el menú desplegable del proyecto y selecciona el proyecto que deseas usar para este codelab.

  1. Habilita las API y los SDK de Google Maps Platform necesarios para este codelab en Google Cloud Marketplace. Para hacerlo, sigue los pasos que se indican en este video o esta documentación.
  2. Genera una clave de API en la página Credenciales de Cloud Console. Puedes seguir los pasos que se indican en este video o esta documentación. Todas las solicitudes a Google Maps Platform requieren una clave de API.

3. El shell de la aplicación y un mapa funcional

En este primer paso, crearemos el diseño visual completo de nuestra aplicación y estableceremos una estructura limpia basada en clases para nuestro código JavaScript. Esto nos brinda una base sólida sobre la cual construir. Al final de esta sección, tendrás una página con diseño que muestra un mapa interactivo.

Crea el archivo HTML

Primero, crea un archivo llamado index.html. Este archivo contendrá la estructura completa de nuestra aplicación, incluidos el encabezado, los filtros de búsqueda, la barra lateral, el contenedor del mapa y los componentes web necesarios.

Copia el siguiente código en index.html. Asegúrate de reemplazar YOUR_API_KEY_HERE por tu propia clave de API de Google Maps Platform y DEMO_MAP_ID por tu propio ID de mapa de Google Maps Platform.

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Local Search App</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- Google Fonts: Roboto -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
    <!-- GMP Bootstrap Loader -->
    <script>
      (g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");for(k in g)e.set(k.replace(/[A-Z]/g,t=>"_"+t[0].toLowerCase()),g[k]);e.set("callback",c+".maps."+q);a.src=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({
        key: "YOUR_API_KEY_HERE",
        v: "weekly",
        libraries: "places,maps,marker,geocoding"
      });
    </script>
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>
  <body>
    <!-- Header for search controls -->
    <header class="top-header">
        <div class="logo">
            <svg viewBox="0 0 24 24" width="28" height="28"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z" fill="currentColor"></path></svg>
            <span>PlaceFinder</span>
        </div>
        <div class="search-container">
            <input
              type="text"
              id="query-input"
              placeholder="e.g., burger in New York"
              value="burger"
            />
            <button id="search-button" aria-label="Search">Search</button>
        </div>
        <div class="filter-container">
            <label class="open-now-label">
              <input type="checkbox" id="open-now-filter"> Open Now
            </label>
            <select id="rating-filter" aria-label="Minimum rating">
              <option value="0" selected>Any rating</option>
              <option value="1">1+ </option>
              <option value="2">2+ ★★</option>
              <option value="3">3+ ★★★</option>
              <option value="4">4+ ★★★★</option>
              <option value="5">5 ★★★★★</option>
            </select>
             <select id="price-filter" aria-label="Price level">
              <option value="0" selected>Any Price</option>
              <option value="1">$</option>
              <option value="2">$$</option>
              <option value="3">$$$</option>
              <option value="4">$$$$</option>
            </select>
        </div>
    </header>

    <!-- Main content area -->
    <div class="app-container">
      <!-- Left Panel: Results -->
      <div class="sidebar">
          <div class="results-header">
            <h2 id="results-header-text">Results</h2>
          </div>
          <div class="results-container">
              <gmp-place-search id="place-search-list" class="hidden" selectable>
                <gmp-place-all-content></gmp-place-all-content>
                <gmp-place-text-search-request></gmp-place-text-search-request>
              </gmp-place-search>

              <div id="placeholder-message" class="placeholder">
                  <p>Your search results will appear here.</p>
              </div>

              <div id="loading-spinner" class="spinner-overlay">
                  <div class="spinner"></div>
              </div>
          </div>
      </div>

      <!-- Right Panel: Map -->
      <div class="map-container">
        <gmp-map
          center="40.758896,-73.985130"
          zoom="13"
          map-id="DEMO_MAP_ID"
        >
        </gmp-map>
        <div id="details-container">
            <gmp-place-details-compact>
                <gmp-place-details-place-request></gmp-place-details-place-request>
                <gmp-place-all-content></gmp-place-all-content>
            </gmp-place-details-compact>
        </div>
      </div>
    </div>
    <script src="script.js"></script>
  </body>
</html>

Crea el archivo CSS

A continuación, crea un archivo llamado style.css. Ahora agregaremos todos los estilos necesarios para establecer un aspecto limpio y moderno desde el principio. Este CSS controla el diseño general, los colores, las fuentes y la apariencia de todos los elementos de la IU.

Copia el siguiente código en 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);
}

Crea la clase de aplicación de JavaScript

Por último, crea un archivo llamado script.js. Estructuraremos nuestra aplicación dentro de una clase de JavaScript llamada PlaceFinderApp. Esto mantiene nuestro código organizado y administra el estado de forma limpia.

Este código inicial definirá la clase, encontrará todos nuestros elementos HTML en el objeto constructor y creará un método init() para cargar las bibliotecas de Google Maps Platform.

Copia el siguiente código en 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();
});

Restricciones de la clave de API

Es posible que debas agregar una restricción nueva a tu clave de API para que este codelab funcione. Consulta Restringe tus claves de API para obtener más información y orientación sobre cómo hacerlo.

Revise su trabajo

Abre el archivo index.html en tu navegador web. Deberías ver una página con un encabezado que contiene una barra de búsqueda y filtros, una barra lateral con el mensaje "Tus resultados de la búsqueda aparecerán aquí" y un mapa grande centrado en la ciudad de Nueva York. En esta etapa, los controles de búsqueda aún no funcionan.

4. Implementa una función de búsqueda

En esta sección, le daremos vida a nuestra aplicación implementando la funcionalidad de búsqueda principal. Escribiremos el código que se ejecuta cuando un usuario hace clic en el botón "Buscar". Compilaremos esta función con prácticas recomendadas desde el principio para controlar las interacciones del usuario de forma correcta y evitar errores comunes, como las condiciones de carrera.

Al final de este paso, podrás hacer clic en el botón de búsqueda y ver un indicador de carga mientras la aplicación recupera datos en segundo plano.

Crea el método de búsqueda

Primero, define el método performSearch dentro de nuestra clase PlaceFinderApp. Esta función será el corazón de nuestra lógica de búsqueda. También presentaremos una variable de instancia, isSearchInProgress, para que actúe como un "portero". Esto evita que el usuario inicie una nueva búsqueda mientras ya hay una en curso, lo que puede generar errores.

La lógica dentro de performSearch puede parecer compleja, por lo que la analizaremos:

  1. Primero, verifica si ya hay una búsqueda en curso. Si es así, no hace nada.
  2. Establece la marca isSearchInProgress en true para "bloquear" la función.
  3. Muestra el spinner de carga y prepara la IU para los resultados nuevos.
  4. Establece la propiedad textQuery de la solicitud de búsqueda en null. Este es un paso crucial que obliga al componente web a reconocer que se está enviando una nueva solicitud.
  5. Usa un setTimeout con una demora de 0. Esta técnica estándar de JavaScript programa el resto de nuestro código para que se ejecute en la siguiente tarea del navegador, lo que garantiza que el componente haya procesado primero el valor de null. Incluso si el usuario busca exactamente lo mismo dos veces, siempre se activará una búsqueda nueva.

Agrega objetos de escucha de eventos

A continuación, debemos llamar a nuestro método performSearch cuando el usuario interactúe con la app. Crearemos un método nuevo, attachEventListeners, para mantener todo nuestro código de control de eventos en un solo lugar. Por ahora, solo agregaremos un objeto de escucha para el evento click del botón de búsqueda. También agregaremos un marcador de posición para otro evento, gmp-load, que usaremos en el siguiente paso.

Actualiza el archivo JavaScript

Actualiza tu archivo script.js con el siguiente código. Las secciones nuevas o modificadas son el método attachEventListeners y el método 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();
});

Revise su trabajo

Guarda el archivo script.js y actualiza index.html en tu navegador. La página debería verse igual que antes. Ahora, haz clic en el botón “Buscar” del encabezado.

Deberías ver dos cosas:

  1. Desaparece el mensaje de marcador de posición "Los resultados de tu búsqueda aparecerán aquí".
  2. Aparece el ícono giratorio de carga y sigue girando.

El spinner girará indefinidamente porque aún no le indicamos cuándo detenerse. Lo haremos en la siguiente sección cuando mostremos los resultados. Esto confirma que nuestra función de búsqueda se activa correctamente.

5. Cómo mostrar los resultados y agregar marcadores

Ahora que el activador de búsqueda funciona, la siguiente tarea es mostrar los resultados en la pantalla. El código de esta sección conectará la lógica de búsqueda a la IU. Una vez que el elemento de Place Search termine de cargar los datos, liberará el "bloqueo" de la búsqueda, ocultará el indicador de carga y mostrará un marcador en el mapa para cada resultado.

Escucha la finalización de la búsqueda

El elemento Place Search activa un evento gmp-load cuando recupera datos correctamente. Esta es la señal perfecta para procesar los resultados.

Primero, agrega un objeto de escucha de eventos para este evento en nuestro método attachEventListeners.

Crea métodos de control de marcadores

A continuación, crearemos dos nuevos métodos de ayuda: clearMarkers y addMarkers.

  • clearMarkers() quitará los marcadores de una búsqueda anterior.
  • Nuestro objeto de escucha gmp-load llamará a addMarkers(). Se ejecutará un bucle en la lista de lugares que devolvió la búsqueda y se creará un nuevo AdvancedMarkerElement para cada uno. Aquí también ocultaremos el ícono giratorio de carga y liberaremos el bloqueo de isSearchInProgress, lo que completará el ciclo de búsqueda.

Observa que almacenamos marcadores en un objeto (this.markers) usando el ID de lugar como clave. Esta es una forma de administrar los marcadores y nos permitirá encontrar uno específico más adelante.

Por último, debemos llamar a clearMarkers() al comienzo de cada búsqueda nueva. El mejor lugar para hacerlo es dentro de performSearch.

Actualiza el archivo JavaScript

Actualiza tu archivo script.js con los métodos nuevos y los cambios en attachEventListeners y 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();
});

Revise su trabajo

Guarda los archivos y actualiza la página en tu navegador. Haz clic en el botón "Buscar".

El ícono giratorio de carga debería aparecer por un momento y, luego, desaparecer. En la barra lateral, se mostrará una lista de lugares relevantes para el término de búsqueda, y deberías ver los marcadores correspondientes en el mapa. Por el momento, los marcadores no hacen nada cuando se hace clic en ellos. Agregaremos esa interactividad en la próxima sección.

6. Activa los filtros de búsqueda y la interactividad de la lista

Nuestra aplicación ahora puede mostrar resultados de la búsqueda, pero aún no es interactiva. En esta sección, haremos que todos los controles del usuario cobren vida. Activaremos los filtros, habilitaremos la búsqueda con la tecla "Intro" y conectaremos los elementos de la lista de resultados con sus ubicaciones correspondientes en el mapa.

Al final de este paso, la aplicación se sentirá completamente responsiva a la entrada del usuario.

Activa los filtros de búsqueda

Primero, se actualizará el método performSearch para leer los valores de todos los controles de filtro del encabezado. Para cada filtro (precio, calificación y "Abierto ahora"), se establecerá la propiedad correspondiente en el objeto searchRequest antes de que se ejecute la búsqueda.

Agrega objetos de escucha de eventos para todos los controles

A continuación, expandiremos nuestro método attachEventListeners. Agregaremos objetos de escucha para el evento change en cada control de filtro, así como un objeto de escucha keydown en la entrada de búsqueda para detectar cuando el usuario presione la tecla "Intro". Todos estos nuevos objetos de escucha llamarán al método performSearch.

Conecta la lista de resultados al mapa

Para crear una experiencia fluida, cuando se hace clic en un elemento de la lista de resultados de la barra lateral, el mapa debe enfocarse en esa ubicación.

Un nuevo método, handleResultClick, escuchará el evento gmp-select, que se activa con el elemento Place Search cuando se hace clic en un elemento. Esta función encontrará la ubicación del lugar asociado y desplazará el mapa suavemente hacia ella.

Para que esto funcione, asegúrate de que el atributo selectable esté presente en tu componente gmp-place-search en 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>

Actualiza el archivo JavaScript

Actualiza el archivo script.js con el siguiente código completo. Esta versión incluye el nuevo método handleResultClick y la lógica actualizada en attachEventListeners y 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();
});

Revise su trabajo

Guarda el archivo script.js y actualiza la página. Ahora la aplicación debería ser muy interactiva.

Verifica lo siguiente:

  • La búsqueda funciona si presionas "Intro" en el cuadro de búsqueda.
  • Si cambias alguno de los filtros (Precio, Calificación, Abierto ahora), se activará una nueva búsqueda y se actualizarán los resultados.
  • Ahora, cuando haces clic en un elemento de la lista de resultados de la barra lateral, el mapa se desplaza suavemente hasta la ubicación de ese elemento.

En la siguiente sección, implementaremos la tarjeta de detalles que aparece cuando se hace clic en un marcador.

7. Implementa el elemento Place Details

Nuestra aplicación ahora es completamente interactiva, pero le falta una función clave: la capacidad de ver más información sobre un lugar seleccionado. En esta sección, implementaremos el elemento Place Details que aparecerá cuando un usuario haga clic en un marcador del mapa o seleccione un elemento en el elemento Place Search.

Crea un contenedor de tarjeta de detalles reutilizable

La forma más eficiente de mostrar los detalles de un lugar en el mapa es crear un solo contenedor reutilizable. Usaremos un AdvancedMarkerElement como este contenedor. Su contenido será el widget gmp-place-details-compact oculto que ya tenemos en nuestro index.html.

Un nuevo método, initDetailsPopup, controlará la creación de este marcador reutilizable. Se creará una vez cuando se cargue la aplicación y comenzará oculta. También agregaremos un objeto de escucha al mapa principal en este método, de modo que, si se hace clic en cualquier parte del mapa, se ocultará la tarjeta de detalles.

Actualiza el comportamiento de clic en el marcador

A continuación, debemos actualizar lo que sucede cuando un usuario hace clic en un marcador de lugar. El objeto de escucha 'click' dentro del método addMarkers ahora será responsable de mostrar la tarjeta de detalles.

Cuando se haga clic en un marcador, el objeto de escucha hará lo siguiente:

  1. Desplaza el mapa hasta la ubicación del marcador.
  2. Actualiza la tarjeta de detalles con la información de ese lugar específico.
  3. Coloca la tarjeta de detalles en la ubicación del marcador y hazla visible.

Conecta el clic en la lista con el clic en el marcador

Por último, actualizaremos el método handleResultClick. En lugar de solo desplazar el mapa, ahora activará de forma programática el evento click en el marcador correspondiente. Este es un patrón eficaz que nos permite reutilizar la misma lógica exacta para ambas interacciones, lo que mantiene nuestro código limpio y fácil de mantener.

Actualiza el archivo JavaScript

Actualiza tu archivo script.js con el siguiente código. Las secciones nuevas o modificadas son el método initDetailsPopup y los métodos addMarkers y handleResultClick actualizados.

// 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();
});

Revise su trabajo

Guarda el archivo script.js y actualiza la página. Ahora, la aplicación debería mostrar detalles a pedido.

Verifica lo siguiente:

  • Al hacer clic en un marcador del mapa, ahora se centra el mapa y se abre una tarjeta de detalles con diseño sobre el marcador.
  • Hacer clic en un elemento de la lista de resultados de la barra lateral tiene exactamente el mismo efecto.
  • Si haces clic en el mapa fuera de la tarjeta, se cerrará.
  • Si inicias una nueva búsqueda, también se cerrará cualquier tarjeta de detalles abierta.

8. Agrega los últimos detalles

Nuestra aplicación ahora es completamente funcional, pero podemos agregar algunos detalles finales para mejorar aún más la experiencia del usuario. En esta última sección, implementaremos dos funciones clave: un encabezado dinámico que proporciona un mejor contexto para los resultados de la búsqueda y un formato automático para la búsqueda del usuario.

Crea un encabezado de resultados dinámico

Por el momento, el encabezado de la barra lateral siempre dice "Resultados". Podemos hacer que sea más informativa si la actualizamos para que refleje la búsqueda actual. Por ejemplo, "Hamburguesas cerca de Nueva York".

Para ello, usaremos la API de Geocoding para convertir las coordenadas centrales del mapa en una ubicación legible por humanos, como el nombre de una ciudad. Un nuevo método async, updateResultsHeader, controlará esta lógica. Se llamará cada vez que se realice una búsqueda.

Da formato a la búsqueda del usuario

Para asegurarnos de que la IU se vea limpia y coherente, formatearemos automáticamente el término de búsqueda del usuario en "Formato de título" (p.ej., "burger restaurant" se convierte en "Burger Restaurant"). Una función auxiliar, toTitleCase, controlará esta transformación. El método performSearch se actualizará para usar esta función en la entrada del usuario antes de realizar la búsqueda y actualizar el encabezado.

Actualiza el archivo JavaScript

Actualiza tu archivo script.js con la versión final del código. Esto incluye los nuevos métodos toTitleCase y updateResultsHeader, y el método performSearch actualizado que los integra.

// 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();
});

Revise su trabajo

Guarda el archivo script.js y actualiza la página.

Verifica las funciones:

  • Escribe pizza (todo en minúsculas) en el cuadro de búsqueda y haz clic en Buscar. El texto del cuadro debería cambiar a "Pizza", y el encabezado de la barra lateral debería actualizarse para indicar "Pizza cerca de Nueva York".
  • Desplaza el mapa a otra ciudad, como Boston, y vuelve a buscar. El encabezado debería actualizarse a "Pizzerías cerca de Boston".

9. Felicitaciones

Creaste correctamente una aplicación de búsqueda local interactiva y completa que combina la simplicidad del kit de IU de Places con la potencia de las APIs principales de JavaScript de Google Maps Platform.

Qué aprendiste

  • Cómo estructurar una aplicación de mapas con una clase de JavaScript para administrar el estado y la lógica
  • Cómo usar el kit de IU de Places con la API de Google Maps JavaScript para desarrollar rápidamente la IU
  • Cómo agregar y administrar marcadores avanzados de forma programática para mostrar lugares de interés personalizados en el mapa
  • Cómo usar el servicio de Geocodificación para convertir coordenadas en direcciones legibles por humanos y mejorar la experiencia del usuario
  • Cómo identificar y corregir condiciones de carrera comunes en una aplicación interactiva usando marcas de estado y asegurándose de que las propiedades de los componentes se actualicen correctamente

Próximos pasos

  • Obtén más información para personalizar los Marcadores avanzados cambiando su color, escala o incluso usando HTML personalizado.
  • Explora el diseño de mapas basado en Cloud para personalizar el aspecto de tu mapa y que coincida con tu marca.
  • Intenta agregar la Biblioteca de dibujo para permitir que los usuarios dibujen formas en el mapa y definan áreas de búsqueda.
  • Responde la siguiente encuesta para ayudarnos a crear el contenido que te resultaría más útil:

¿Qué otros codelabs te gustaría ver?

Visualización de datos en mapas Más información para personalizar el diseño de mis mapas Cómo crear interacciones en 3D en mapas

¿No encuentras el codelab que más te interesa? Crea un nuevo problema aquí para solicitarlo.