장소 UI 키트로 지역 검색 앱 빌드

1. 시작하기 전에

이 Codelab에서는 Google Maps Platform 장소 UI 키트를 사용하여 완전한 대화형 지역 검색 애플리케이션을 빌드하는 방법을 알아봅니다.

완성된 PlaceFinder 애플리케이션의 스크린샷으로, 마커가 있는 뉴욕 지도, 검색 결과가 있는 사이드바, 세부정보 카드가 열려 있습니다.

기본 요건

  • 필요한 API와 사용자 인증 정보가 구성된 Google Cloud 프로젝트
  • HTML 및 CSS에 대한 기본 지식
  • 최신 JavaScript에 대한 이해
  • 최신 버전의 Chrome과 같은 최신 웹브라우저
  • 원하는 텍스트 편집기

실습할 내용

  • JavaScript 클래스를 사용하여 매핑 애플리케이션을 구조화합니다.
  • 웹 구성요소를 사용하여 지도 표시
  • 장소 검색 요소를 사용하여 텍스트 검색을 실행하고 결과를 표시합니다.
  • 프로그래매틱 방식으로 맞춤 AdvancedMarkerElement 지도 마커를 만들고 관리합니다.
  • 사용자가 위치를 선택하면 장소 세부정보 요소를 표시합니다.
  • Geocoding API를 사용하여 동적이고 사용자 친화적인 인터페이스를 만드세요.

필요한 항목

  • 결제가 사용 설정된 Google Cloud 프로젝트
  • Google Maps Platform API 키
  • 지도 ID
  • 다음 API가 사용 설정됩니다.
    • Maps JavaScript API
    • 장소 UI 키트
    • Geocoding API

2. 설정

다음 사용 설정 단계에서는 Maps JavaScript API, Places UI 키트, Geocoding API를 사용 설정해야 합니다.

Google Maps Platform 설정하기

Google Cloud Platform 계정 및 결제가 사용 설정된 프로젝트가 없는 경우 Google Maps Platform 시작하기 가이드를 참고하여 결제 계정 및 프로젝트를 만듭니다.

  1. Cloud Console에서 프로젝트 드롭다운 메뉴를 클릭하고 이 Codelab에 사용할 프로젝트를 선택합니다.

  1. Google Cloud Marketplace에서 이 Codelab에 필요한 Google Maps Platform API 및 SDK를 사용 설정합니다. 사용 설정을 위해 이 동영상 또는 이 문서에서 설명하고 있는 단계를 따르세요.
  2. Cloud Console의 사용자 인증 정보 페이지에서 API 키를 생성합니다. 이 동영상 또는 이 문서에서 설명하고 있는 단계를 따릅니다. Google Maps Platform에 대한 모든 요청에는 API 키가 필요합니다.

3. 애플리케이션 셸 및 기능 지도

이 첫 번째 단계에서는 애플리케이션의 전체 시각적 레이아웃을 만들고 JavaScript의 깔끔한 클래스 기반 구조를 설정합니다. 이를 통해 탄탄한 기반을 구축할 수 있습니다. 이 섹션이 끝나면 대화형 지도를 표시하는 스타일이 지정된 페이지가 완성됩니다.

HTML 파일 만들기

먼저 index.html이라는 파일을 만듭니다. 이 파일에는 헤더, 검색 필터, 사이드바, 지도 컨테이너, 필요한 웹 구성요소를 비롯한 애플리케이션의 전체 구조가 포함됩니다.

다음 코드를 index.html에 복사합니다. YOUR_API_KEY_HERE를 자체 Google Maps Platform API 키로, DEMO_MAP_ID를 자체 Google Maps Platform 지도 ID로 바꿔야 합니다.

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

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

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

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

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

CSS 파일 만들기

그런 다음 style.css 파일을 만듭니다. 이제 처음부터 깔끔하고 현대적인 디자인을 만들기 위해 필요한 스타일을 모두 추가하겠습니다. 이 CSS는 전반적인 레이아웃, 색상, 글꼴, 모든 UI 요소의 모양을 처리합니다.

다음 코드를 style.css에 복사합니다.

/* style.css */
:root {
  --primary-color: #1a73e8;
  --text-color: #202124;
  --text-color-light: #5f6368;
  --background-color: #f8f9fa;
  --panel-background: #ffffff;
  --border-color: #dadce0;
  --shadow-color: rgba(0, 0, 0, 0.1);
}

body {
  font-family: 'Roboto', sans-serif;
  margin: 0;
  height: 100vh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background-color: var(--background-color);
  color: var(--text-color);
}

.hidden {
  display: none !important;
}

.top-header {
  display: flex;
  align-items: center;
  padding: 12px 24px;
  border-bottom: 1px solid var(--border-color);
  background-color: var(--panel-background);
  gap: 24px;
  flex-shrink: 0;
}

.logo {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 22px;
  font-weight: 700;
  color: var(--primary-color);
}

.search-container {
  display: flex;
  flex-grow: 1;
  max-width: 720px;
}

.search-container input {
  width: 100%;
  padding: 12px 16px;
  border: 1px solid var(--border-color);
  border-radius: 8px 0 0 8px;
  font-size: 16px;
  transition: box-shadow 0.2s ease;
}

.search-container input:focus {
  outline: none;
  border-color: var(--primary-color);
  box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}

.search-container button {
  padding: 0 20px;
  border: 1px solid var(--primary-color);
  border-radius: 0 8px 8px 0;
  background-color: var(--primary-color);
  color: white;
  cursor: pointer;
  font-size: 16px;
  font-weight: 500;
  transition: background-color 0.2s ease;
}

.search-container button:hover {
  background-color: #185abc;
}

.filter-container {
  display: flex;
  gap: 12px;
  align-items: center;
}

.filter-container select, .open-now-label {
  padding: 10px 14px;
  border: 1px solid var(--border-color);
  border-radius: 8px;
  background-color: var(--panel-background);
  font-size: 14px;
  cursor: pointer;
  transition: border-color 0.2s ease;
}

.filter-container select:hover, .open-now-label:hover {
  border-color: #c0c2c5;
}

.open-now-label {
  display: flex;
  align-items: center;
  gap: 8px;
  white-space: nowrap;
}

.app-container {
  display: flex;
  flex-grow: 1;
  overflow: hidden;
}

.sidebar {
  width: 35%;
  min-width: 380px;
  max-width: 480px;
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--border-color);
  background-color: var(--panel-background);
  overflow: hidden;
}

.results-header {
  padding: 16px 24px;
  border-bottom: 1px solid var(--border-color);
  flex-shrink: 0;
}

.results-header h2 {
  margin: 0;
  font-size: 18px;
  font-weight: 500;
}

.results-container {
  flex-grow: 1;
  position: relative;
  overflow-y: auto;
  overflow-x: hidden;
}

.placeholder {
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  padding: 2rem;
  box-sizing: border-box;
}

.placeholder p {
  color: var(--text-color-light);
  font-size: 1.1rem;
}

gmp-place-search {
  width: 100%;
}

.map-container {
  flex-grow: 1;
  position: relative;
}

gmp-map {
  width: 100%;
  height: 100%;
}

.spinner-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(255, 255, 255, 0.7);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 100;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s, visibility 0.3s;
}
.spinner-overlay.visible {
  opacity: 1;
  visibility: visible;
}
.spinner {
  width: 48px;
  height: 48px;
  border: 4px solid #e0e0e0;
  border-top-color: var(--primary-color);
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}

gmp-place-details-compact {
  width: 350px;
  display: none;
  border: none;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}

gmp-place-details-compact::after {
    content: '';
    position: absolute;
    bottom: -12px;
    left: 50%;
    transform: translateX(-50%);
    width: 24px;
    height: 12px;
    background-color: var(--panel-background);
    clip-path: polygon(50% 100%, 0 0, 100% 0);
}

JavaScript 애플리케이션 클래스 만들기

마지막으로 script.js이라는 파일을 만듭니다. PlaceFinderApp이라는 JavaScript 클래스 내에 애플리케이션을 구성합니다. 이렇게 하면 코드가 체계적으로 유지되고 상태가 깔끔하게 관리됩니다.

이 초기 코드는 클래스를 정의하고, constructor에서 모든 HTML 요소를 찾고, Google Maps Platform 라이브러리를 로드하는 init() 메서드를 만듭니다.

다음 코드를 script.js에 복사합니다.

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

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

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

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

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

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

    // We will add more initialization logic here in later steps.
  }
}

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

API 키 제한사항

이 Codelab이 작동하려면 API 키에 새 제한사항을 추가해야 할 수 있습니다. 자세한 내용과 방법을 알아보려면 API 키 제한을 참고하세요.

학습 내용 확인

웹브라우저에서 index.html 파일을 엽니다. 검색창과 필터가 포함된 헤더, '검색 결과가 여기에 표시됩니다'라는 메시지가 표시된 사이드바, 뉴욕시를 중심으로 한 대형 지도가 표시된 페이지가 표시됩니다. 이 단계에서는 검색 컨트롤이 아직 작동하지 않습니다.

4. 검색 기능 구현

이 섹션에서는 핵심 검색 기능을 구현하여 애플리케이션을 활성화합니다. 사용자가 '검색' 버튼을 클릭할 때 실행되는 코드를 작성합니다. Google에서는 사용자 상호작용을 원활하게 처리하고 경합 상태와 같은 일반적인 버그를 방지하기 위해 처음부터 권장사항을 적용하여 이 함수를 빌드할 예정입니다.

이 단계를 완료하면 검색 버튼을 클릭할 수 있으며 애플리케이션이 백그라운드에서 데이터를 가져오는 동안 로딩 스피너가 표시됩니다.

검색 메서드 만들기

먼저 PlaceFinderApp 클래스 내에서 performSearch 메서드를 정의합니다. 이 함수는 검색 로직의 핵심이 됩니다. '게이트키퍼' 역할을 하는 인스턴스 변수 isSearchInProgress도 도입합니다. 이렇게 하면 사용자가 검색이 진행 중인 동안 새 검색을 시작할 수 없으므로 오류가 발생하지 않습니다.

performSearch 내부의 로직이 복잡해 보일 수 있으므로 이를 분석해 보겠습니다.

  1. 먼저 검색이 이미 진행 중인지 확인합니다. 그렇다면 아무 작업도 실행되지 않습니다.
  2. isSearchInProgress 플래그를 true로 설정하여 함수를 '잠급니다'.
  3. 로딩 스피너를 표시하고 새 결과를 위해 UI를 준비합니다.
  4. 검색 요청의 textQuery 속성을 null로 설정합니다. 이는 웹 구성요소가 새 요청이 들어오고 있음을 인식하도록 하는 중요한 단계입니다.
  5. 0 지연이 있는 setTimeout를 사용합니다. 이 표준 JavaScript 기법은 구성요소가 null 값을 먼저 처리하도록 하여 나머지 코드가 다음 브라우저 작업에서 실행되도록 예약합니다. 사용자가 동일한 내용을 두 번 검색하더라도 항상 새로운 검색이 트리거됩니다.

이벤트 리스너 추가

다음으로 사용자가 앱과 상호작용할 때 performSearch 메서드를 호출해야 합니다. 모든 이벤트 처리 코드를 한곳에 유지하기 위해 새 메서드 attachEventListeners를 만듭니다. 지금은 검색 버튼의 click 이벤트에 대한 리스너만 추가합니다. 다음 단계에서 사용할 다른 이벤트 gmp-load의 자리표시자도 추가합니다.

JavaScript 파일 업데이트

다음 코드를 사용하여 script.js 파일을 업데이트합니다. 새로 추가되거나 변경된 섹션은 attachEventListeners 메서드와 performSearch 메서드입니다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

학습 내용 확인

script.js 파일을 저장하고 브라우저에서 index.html을 새로고침합니다. 페이지는 이전과 동일하게 표시됩니다. 이제 헤더에서 '검색' 버튼을 클릭합니다.

다음 두 가지가 표시됩니다.

  1. '검색 결과가 여기에 표시됩니다'라는 자리표시자 메시지가 사라집니다.
  2. 로딩 스피너가 표시되고 계속 회전합니다.

언제 중지해야 하는지 아직 알려주지 않았으므로 스피너가 계속 회전합니다. 결과를 표시할 때 다음 섹션에서 이 작업을 수행합니다. 이렇게 하면 검색 기능이 올바르게 트리거되었는지 확인할 수 있습니다.

5. 결과 표시 및 마커 추가

이제 검색 트리거가 작동하므로 다음 작업은 화면에 결과를 표시하는 것입니다. 이 섹션의 코드는 검색 로직을 UI에 연결합니다. 장소 검색 요소가 데이터 로드를 완료하면 검색 '잠금'이 해제되고 로드 스피너가 숨겨지며 각 결과에 대한 마커가 지도에 표시됩니다.

검색 완료 리스너

장소 검색 요소는 데이터를 성공적으로 가져오면 gmp-load 이벤트를 발생시킵니다. 결과를 처리하기에 완벽한 신호입니다.

먼저 attachEventListeners 메서드에 이 이벤트의 이벤트 리스너를 추가합니다.

마커 처리 메서드 만들기

다음으로 clearMarkersaddMarkers이라는 두 개의 새로운 도우미 메서드를 만듭니다.

  • clearMarkers()는 이전 검색의 마커를 삭제합니다.
  • addMarkers()gmp-load 리스너에 의해 호출됩니다. 검색에서 반환된 장소 목록을 반복하고 각 장소에 대해 새 AdvancedMarkerElement를 만듭니다. 여기에서 로드 스피너를 숨기고 isSearchInProgress 잠금을 해제하여 검색 주기를 완료합니다.

지역 ID를 키로 사용하여 객체 (this.markers)에 마커를 저장합니다. 이렇게 하면 마커를 관리할 수 있으며 나중에 특정 마커를 찾을 수 있습니다.

마지막으로, 새 검색이 시작될 때마다 clearMarkers()을 호출해야 합니다. 이 코드는 performSearch 내부에 배치하는 것이 가장 좋습니다.

JavaScript 파일 업데이트

새 메서드와 attachEventListenersperformSearch 변경사항으로 script.js 파일을 업데이트합니다.

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

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

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

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

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

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

    this.attachEventListeners();
  }

  attachEventListeners() {
    this.searchButton.addEventListener('click', this.performSearch.bind(this));
    // NEW: Listen for when the search component has loaded results
    this.placeSearch.addEventListener('gmp-load', this.addMarkers.bind(this));
  }

  // NEW: Method to clear markers from a previous search
  clearMarkers() {
    for (const marker of Object.values(this.markers)) {
      marker.map = null;
    }
    this.markers = {};
  }

  // NEW: Method to add markers for new search results
  addMarkers() {
    // Release the lock and hide the spinner
    this.isSearchInProgress = false;
    this.showLoading(false);

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

    // Create a new marker for each place result
    for (const place of places) {
      if (!place.location || !place.id) continue;
      const marker = new this.AdvancedMarkerElement({
        map: this.map,
        position: place.location,
        title: place.displayName,
      });
      // Store marker by its place ID for access later
      this.markers[place.id] = marker;
    }
  }

  async performSearch() {
    if (this.isSearchInProgress) {
      return;
    }
    this.isSearchInProgress = true;
    this.placeholderMessage.classList.add('hidden');
    this.placeSearch.classList.remove('hidden');
    this.showLoading(true);

    // NEW: Clear old markers before starting a new search
    this.clearMarkers();

    this.searchRequest.textQuery = null;

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

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

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

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

학습 내용 확인

파일을 저장하고 브라우저에서 페이지를 새로고침합니다. '검색' 버튼을 클릭합니다.

이제 로딩 스피너가 잠시 표시되었다가 사라집니다. 사이드바에 검색어와 관련된 장소 목록이 표시되고 지도에 해당 마커가 표시됩니다. 아직 마커를 클릭해도 아무 작업도 실행되지 않습니다. 다음 섹션에서 상호작용을 추가할 예정입니다.

6. 검색 필터 및 목록 상호작용 활성화

이제 애플리케이션에서 검색 결과를 표시할 수 있지만 아직 대화형은 아닙니다. 이 섹션에서는 모든 사용자 컨트롤을 구현합니다. 필터가 활성화되고, 'Enter' 키로 검색이 가능해지며, 결과 목록의 항목이 지도상의 해당 위치에 연결됩니다.

이 단계를 마치면 애플리케이션이 사용자 입력에 완전히 반응하는 것처럼 느껴질 것입니다.

검색 필터 활성화

먼저 performSearch 메서드가 헤더의 모든 필터 컨트롤에서 값을 읽도록 업데이트됩니다. 각 필터 (가격, 평점, '지금 영업 중')의 경우 검색이 실행되기 전에 searchRequest 객체에 해당 속성이 설정됩니다.

모든 컨트롤에 이벤트 리스너 추가

다음으로 attachEventListeners 메서드를 확장합니다. 각 필터 컨트롤에 change 이벤트 리스너를 추가하고 검색 입력에 keydown 리스너를 추가하여 사용자가 'Enter' 키를 누르는 시점을 감지합니다. 이러한 새 리스너는 모두 performSearch 메서드를 호출합니다.

결과 목록을 지도에 연결

원활한 환경을 만들려면 사이드바 목록에서 항목을 클릭하면 해당 위치에 지도가 포커스되어야 합니다.

새 메서드인 handleResultClick는 항목을 클릭할 때 장소 검색 요소에서 발생하는 gmp-select 이벤트를 수신 대기합니다. 이 함수는 연결된 장소의 위치를 찾아 지도를 해당 위치로 부드럽게 이동합니다.

이 기능이 작동하려면 index.htmlgmp-place-search 구성요소에 selectable 속성이 있어야 합니다.

<gmp-place-search id="place-search-list" class="hidden" selectable>
    <gmp-place-all-content></gmp-place-all-content>
    <gmp-place-text-search-request></gmp-place-text-search-request>
</gmp-place-search>

JavaScript 파일 업데이트

다음 전체 코드를 사용하여 script.js 파일을 업데이트합니다. 이 버전에는 새로운 handleResultClick 메서드와 attachEventListenersperformSearch의 업데이트된 로직이 포함되어 있습니다.

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

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

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

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

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

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

    this.attachEventListeners();
  }

  // UPDATED: All event listeners are now attached
  attachEventListeners() {
    // Listen for the 'Enter' key press in the search input
    this.queryInput.addEventListener('keydown', (event) => {
      if (event.key === 'Enter') {
        event.preventDefault();
        this.performSearch();
      }
    });

    // Listen for a sidebar result click
    this.placeSearch.addEventListener('gmp-select', this.handleResultClick.bind(this));

    this.placeSearch.addEventListener('gmp-load', this.addMarkers.bind(this));
    this.searchButton.addEventListener('click', this.performSearch.bind(this));
    this.priceFilter.addEventListener('change', this.performSearch.bind(this));
    this.ratingFilter.addEventListener('change', this.performSearch.bind(this));
    this.openNowFilter.addEventListener('change', this.performSearch.bind(this));
  }

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

  addMarkers() {
    this.isSearchInProgress = false;
    this.showLoading(false);

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

    for (const place of places) {
      if (!place.location || !place.id) continue;
      const marker = new this.AdvancedMarkerElement({
        map: this.map,
        position: place.location,
        title: place.displayName,
      });
      this.markers[place.id] = marker;
    }
  }

  // NEW: Function to handle clicks on the results list
  handleResultClick(event) {
    const place = event.place;
    if (!place || !place.location) return;
    // Pan the map to the selected place
    this.map.panTo(place.location);
  }

  // UPDATED: Search function now includes all filters
  async performSearch() {
    if (this.isSearchInProgress) {
      return;
    }
    this.isSearchInProgress = true;
    this.placeholderMessage.classList.add('hidden');
    this.placeSearch.classList.remove('hidden');
    this.showLoading(true);
    this.clearMarkers();

    this.searchRequest.textQuery = null;

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

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

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

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

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

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

학습 내용 확인

script.js 파일을 저장하고 페이지를 새로고침합니다. 이제 애플리케이션이 매우 상호작용적이어야 합니다.

다음을 확인합니다.

  • 검색창에서 'Enter' 키를 눌러 검색하는 것은 작동합니다.
  • 필터 (가격, 평점, 영업 중)를 변경하면 새 검색이 트리거되고 결과가 업데이트됩니다.
  • 이제 사이드바 목록에서 항목을 클릭하면 지도가 해당 항목의 위치로 부드럽게 이동합니다.

다음 섹션에서는 마커를 클릭할 때 표시되는 세부정보 카드를 구현합니다.

7. 장소 세부정보 요소 구현

이제 애플리케이션이 완전히 대화형이지만 선택한 장소에 관한 자세한 정보를 볼 수 있는 기능이 누락되어 있습니다. 이 섹션에서는 사용자가 지도에서 마커를 클릭하거나 장소 검색 요소에서 항목을 선택할 때 표시되는 장소 세부정보 요소를 구현합니다.

재사용 가능한 세부정보 카드 컨테이너 만들기

지도에 장소 세부정보를 표시하는 가장 효율적인 방법은 재사용 가능한 단일 컨테이너를 만드는 것입니다. 이 컨테이너로는 AdvancedMarkerElement를 사용합니다. 콘텐츠는 index.html에 이미 있는 숨겨진 gmp-place-details-compact 위젯입니다.

새 메서드인 initDetailsPopup가 이 재사용 가능한 마커의 생성을 처리합니다. 애플리케이션이 로드될 때 한 번 생성되며 숨겨진 상태로 시작됩니다. 또한 이 메서드에서 기본 지도에 리스너를 추가하여 지도의 아무 곳이나 클릭하면 세부정보 카드가 숨겨지도록 합니다.

마커 클릭 동작 업데이트

다음으로 사용자가 장소 마커를 클릭할 때 발생하는 결과를 업데이트해야 합니다. 이제 addMarkers 메서드 내의 'click' 리스너가 세부정보 카드를 표시합니다.

마커를 클릭하면 리스너는 다음 작업을 실행합니다.

  1. 지도를 마커 위치로 이동합니다.
  2. 세부정보 카드를 해당 특정 장소의 정보로 업데이트합니다.
  3. 세부정보 카드를 마커의 위치에 배치하고 표시합니다.

목록 클릭을 마커 클릭에 연결

마지막으로 handleResultClick 메서드를 업데이트합니다. 이제 지도를 이동하는 대신 해당 마커에서 click 이벤트가 프로그래매틱 방식으로 트리거됩니다. 이는 두 상호작용 모두에 정확히 동일한 로직을 재사용하여 코드를 깔끔하고 유지관리 가능하게 유지할 수 있는 강력한 패턴입니다.

JavaScript 파일 업데이트

다음 코드를 사용하여 script.js 파일을 업데이트합니다. 새로 추가되거나 변경된 섹션은 initDetailsPopup 메서드와 업데이트된 addMarkershandleResultClick 메서드입니다.

// 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가 이 로직을 처리합니다. 검색이 실행될 때마다 호출됩니다.

사용자의 검색어 형식 지정

UI가 깔끔하고 일관성 있게 표시되도록 사용자의 검색어가 자동으로 '제목 케이스' (예: '버거 레스토랑'이 'Burger Restaurant'으로 바뀝니다. 도우미 함수 toTitleCase가 이 변환을 처리합니다. 검색을 실행하고 헤더를 업데이트하기 전에 사용자의 입력에 이 함수를 사용하도록 performSearch 메서드가 업데이트됩니다.

JavaScript 파일 업데이트

최종 버전의 코드로 script.js 파일을 업데이트합니다. 여기에는 새로운 toTitleCaseupdateResultsHeader 메서드와 이를 통합하는 업데이트된 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 키트의 간편함과 핵심 Google Maps Platform JavaScript API의 강력한 기능을 결합한 완전한 대화형 지역 검색 애플리케이션을 성공적으로 빌드했습니다.

학습한 내용

  • JavaScript 클래스를 사용하여 상태와 로직을 관리하는 매핑 애플리케이션을 구조화하는 방법
  • Google Maps JavaScript API와 함께 Places UI 키트를 사용하여 UI를 빠르게 개발하는 방법
  • 프로그래매틱 방식으로 고급 마커를 추가하고 관리하여 지도에 맞춤 관심 장소를 표시하는 방법
  • Geocoding Service를 사용하여 좌표를 사람이 읽을 수 있는 주소로 변환하여 사용자 환경을 개선하는 방법
  • 상태 플래그를 사용하고 구성요소 속성이 올바르게 업데이트되었는지 확인하여 대화형 애플리케이션에서 일반적인 경합 상태를 식별하고 수정하는 방법

다음 단계

  • 색상, 크기를 변경하거나 맞춤 HTML을 사용하여 고급 마커를 맞춤설정하는 방법을 자세히 알아보세요.
  • 클라우드 기반 지도 스타일 지정을 살펴보고 브랜드에 맞게 지도의 디자인과 분위기를 맞춤설정하세요.
  • 그리기 라이브러리를 추가하여 사용자가 지도에 도형을 그려 검색 영역을 정의할 수 있도록 해 보세요.
  • 다음 설문조사에 답하여 Google에서 가장 유용한 콘텐츠를 만들 수 있도록 도와주세요.

다른 Codelab에서 어떤 내용을 다뤘으면 하시나요?

지도에서의 데이터 시각화 내 지도의 스타일 맞춤설정에 관한 추가 정보 지도에서 3D 상호작용 만들기

가장 관심 있는 Codelab을 찾을 수 없나요? 여기에서 새로운 문제로 요청하세요.