1. Trước khi bắt đầu
Lớp học lập trình này hướng dẫn bạn cách tạo một ứng dụng tìm kiếm địa phương có tính tương tác đầy đủ bằng cách sử dụng Google Maps Platform Places UI Kit.
Điều kiện tiên quyết
- Một dự án Google Cloud có các API và thông tin đăng nhập cần thiết đã được định cấu hình.
- Có kiến thức cơ bản về HTML và CSS.
- Hiểu biết về JavaScript hiện đại.
- Một trình duyệt web hiện đại, chẳng hạn như phiên bản Chrome mới nhất.
- Một trình chỉnh sửa văn bản mà bạn chọn.
Bạn sẽ thực hiện
- Cấu trúc một ứng dụng lập bản đồ bằng cách sử dụng một lớp JavaScript.
- Sử dụng Thành phần web để hiển thị bản đồ
- Sử dụng phần tử Tìm kiếm địa điểm để thực hiện và hiển thị kết quả của một cụm từ tìm kiếm.
- Tạo và quản lý điểm đánh dấu tuỳ chỉnh trên bản đồ
AdvancedMarkerElement
theo cách lập trình. - Hiển thị Phần tử thông tin chi tiết về địa điểm khi người dùng chọn một vị trí.
- Sử dụng Geocoding API để tạo một giao diện linh động và thân thiện với người dùng.
Bạn cần có
- Một dự án trên Google Cloud đã bật tính năng thanh toán
- Khoá API Nền tảng Google Maps
- Mã bản đồ
- Đã bật các API sau:
- API JavaScript cho Maps
- Places UI Kit
- Geocoding API
2. Bắt đầu thiết lập
Đối với bước bật sau đây, bạn cần bật Maps JavaScript API, Places UI Kit và Geocoding API.
Thiết lập Nền tảng Google Maps
Nếu bạn chưa có tài khoản Google Cloud Platform và dự án có bật tính năng thanh toán, vui lòng xem hướng dẫn Bắt đầu sử dụng Google Maps Platform để tạo tài khoản thanh toán và dự án.
- Trong Cloud Console, hãy nhấp vào trình đơn thả xuống dự án rồi chọn dự án mà bạn muốn sử dụng cho lớp học lập trình này.
- Bật các API và SDK của Google Maps Platform cần thiết cho lớp học lập trình này trong Google Cloud Marketplace. Để làm như vậy, hãy làm theo các bước trong video này hoặc tài liệu này.
- Tạo khoá API trong trang Thông tin xác thực của Cloud Console. Bạn có thể làm theo các bước trong video này hoặc tài liệu này. Tất cả các yêu cầu gửi đến Nền tảng Google Maps đều cần có khoá API.
3. Giao diện ứng dụng và bản đồ có chức năng
Trong bước đầu tiên này, chúng ta sẽ tạo bố cục trực quan hoàn chỉnh cho ứng dụng và thiết lập một cấu trúc rõ ràng dựa trên lớp cho JavaScript. Điều này mang đến cho chúng tôi một nền tảng vững chắc để phát triển. Khi kết thúc phần này, bạn sẽ có một trang được tạo kiểu hiển thị bản đồ tương tác.
Tạo tệp HTML
Trước tiên, hãy tạo một tệp có tên là index.html
. Tệp này sẽ chứa cấu trúc hoàn chỉnh của ứng dụng, bao gồm tiêu đề, bộ lọc tìm kiếm, thanh bên, vùng chứa bản đồ và các thành phần web cần thiết.
Sao chép mã sau vào index.html
. Nhớ thay thế YOUR_API_KEY_HERE
bằng khoá API Nền tảng Google Maps của riêng bạn và DEMO_MAP_ID
bằng mã bản đồ Nền tảng Google Maps của riêng bạn.
<!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>
Tạo tệp CSS
Tiếp theo, hãy tạo một tệp có tên là style.css
. Giờ đây, chúng ta sẽ thêm tất cả kiểu cần thiết để tạo một giao diện hiện đại, gọn gàng ngay từ đầu. CSS này xử lý bố cục tổng thể, màu sắc, phông chữ và giao diện của tất cả các phần tử trên giao diện người dùng.
Sao chép mã sau vào 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);
}
Tạo lớp ứng dụng JavaScript
Cuối cùng, hãy tạo một tệp có tên là script.js
. Chúng ta sẽ cấu trúc ứng dụng của mình trong một lớp JavaScript có tên là PlaceFinderApp
. Việc này giúp mã của chúng ta luôn gọn gàng và quản lý trạng thái một cách rõ ràng.
Đoạn mã ban đầu này sẽ xác định lớp, tìm tất cả các phần tử HTML trong constructor
và tạo một phương thức init()
để tải các thư viện Google Maps Platform.
Sao chép mã sau vào 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();
});
Các quy tắc hạn chế đối với khoá API
Bạn có thể cần thêm một quy tắc hạn chế mới vào khoá API để Lớp học lập trình này hoạt động. Hãy xem phần Hạn chế khoá API để biết thêm thông tin và hướng dẫn về cách thực hiện việc này.
Kiểm tra bài tập
Mở tệp index.html
trong trình duyệt web. Bạn sẽ thấy một trang có tiêu đề chứa thanh tìm kiếm và bộ lọc, một thanh bên có thông báo "Kết quả tìm kiếm của bạn sẽ xuất hiện ở đây" và một bản đồ lớn tập trung vào Thành phố New York. Ở giai đoạn này, các chế độ kiểm soát hoạt động tìm kiếm chưa hoạt động.
4. Triển khai một hàm tìm kiếm
Trong phần này, chúng ta sẽ đưa ứng dụng vào hoạt động bằng cách triển khai chức năng tìm kiếm cốt lõi. Chúng ta sẽ viết mã chạy khi người dùng nhấp vào nút "Tìm kiếm". Chúng ta sẽ xây dựng chức năng này theo các phương pháp hay ngay từ đầu để xử lý các hoạt động tương tác của người dùng một cách hiệu quả và ngăn chặn các lỗi thường gặp như điều kiện xung đột.
Đến cuối bước này, bạn sẽ có thể nhấp vào nút tìm kiếm và thấy một biểu tượng tải xoay tròn xuất hiện trong khi ứng dụng tìm nạp dữ liệu ở chế độ nền.
Tạo phương thức tìm kiếm
Trước tiên, hãy xác định phương thức performSearch
bên trong lớp PlaceFinderApp
. Hàm này sẽ là cốt lõi của logic tìm kiếm. Chúng ta cũng sẽ giới thiệu một biến thực thể, isSearchInProgress
, để đóng vai trò là "người gác cổng". Điều này ngăn người dùng bắt đầu một lượt tìm kiếm mới trong khi một lượt tìm kiếm đang diễn ra, điều này có thể dẫn đến lỗi.
Logic bên trong performSearch
có vẻ phức tạp, vì vậy, chúng ta sẽ phân tích logic này:
- Trước tiên, nó sẽ kiểm tra xem có đang diễn ra quá trình tìm kiếm nào hay không. Nếu có, thì không có gì xảy ra.
- Thao tác này đặt cờ
isSearchInProgress
thànhtrue
để "khoá" hàm. - Thao tác này cho thấy biểu tượng tải và chuẩn bị giao diện người dùng cho kết quả mới.
- Thao tác này đặt thuộc tính
textQuery
của yêu cầu tìm kiếm thànhnull
. Đây là một bước quan trọng buộc thành phần web phải nhận ra rằng có một yêu cầu mới đang đến. - Hàm này sử dụng
setTimeout
với độ trễ0
. Kỹ thuật JavaScript tiêu chuẩn này lên lịch cho phần còn lại của mã để chạy trong tác vụ trình duyệt tiếp theo, đảm bảo thành phần đã xử lý giá trịnull
trước. Ngay cả khi người dùng tìm kiếm chính xác cùng một nội dung hai lần, thì một lượt tìm kiếm mới sẽ luôn được kích hoạt.
Thêm trình nghe sự kiện
Tiếp theo, chúng ta cần gọi phương thức performSearch
khi người dùng tương tác với ứng dụng. Chúng ta sẽ tạo một phương thức mới là attachEventListeners
để giữ tất cả mã xử lý sự kiện ở một nơi. Hiện tại, chúng ta chỉ cần thêm một trình nghe cho sự kiện click
của nút tìm kiếm. Chúng ta cũng sẽ thêm một phần giữ chỗ cho một sự kiện khác, gmp-load
, mà chúng ta sẽ sử dụng trong bước tiếp theo.
Cập nhật tệp JavaScript
Cập nhật tệp script.js
bằng đoạn mã sau. Các phần mới hoặc đã thay đổi là phương thức attachEventListeners
và phương thức 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();
});
Kiểm tra bài tập
Lưu tệp script.js
và làm mới index.html
trong trình duyệt. Trang này sẽ có giao diện giống như trước. Bây giờ, hãy nhấp vào nút "Tìm kiếm" trong tiêu đề.
Bạn sẽ thấy hai điều xảy ra:
- Thông báo giữ chỗ "Kết quả tìm kiếm của bạn sẽ xuất hiện tại đây" sẽ biến mất.
- Vòng quay đang tải sẽ xuất hiện và tiếp tục quay.
Vòng quay sẽ quay mãi mãi vì chúng ta chưa cho nó biết khi nào thì dừng. Chúng ta sẽ làm việc đó trong phần tiếp theo khi hiển thị kết quả. Điều này xác nhận rằng chức năng tìm kiếm của chúng tôi đang được kích hoạt đúng cách.
5. Hiển thị kết quả và thêm điểm đánh dấu
Giờ đây, khi trình kích hoạt tìm kiếm đã hoạt động, nhiệm vụ tiếp theo là hiển thị kết quả trên màn hình. Mã trong phần này sẽ kết nối logic tìm kiếm với giao diện người dùng. Sau khi tải xong dữ liệu, Phần tử tìm kiếm địa điểm sẽ giải phóng "khoá" tìm kiếm, ẩn biểu tượng tải và hiển thị một điểm đánh dấu trên bản đồ cho mỗi kết quả.
Nghe để hoàn tất nội dung tìm kiếm
Phần tử Tìm kiếm địa điểm sẽ kích hoạt sự kiện gmp-load
khi tìm nạp dữ liệu thành công. Đây là tín hiệu hoàn hảo để chúng tôi xử lý kết quả.
Trước tiên, hãy thêm một trình nghe sự kiện cho sự kiện này trong phương thức attachEventListeners
.
Tạo các phương thức xử lý điểm đánh dấu
Tiếp theo, chúng ta sẽ tạo 2 phương thức trợ giúp mới: clearMarkers
và addMarkers
.
clearMarkers()
sẽ xoá mọi điểm đánh dấu khỏi một lượt tìm kiếm trước đó.addMarkers()
sẽ được trình nghegmp-load
của chúng tôi gọi. Thao tác này sẽ lặp lại danh sách các địa điểm do kết quả tìm kiếm trả về và tạo mộtAdvancedMarkerElement
mới cho từng địa điểm. Đây cũng là nơi chúng ta sẽ ẩn biểu tượng tải và giải phóng khoáisSearchInProgress
, hoàn tất chu trình tìm kiếm.
Xin lưu ý rằng chúng ta đang lưu trữ các điểm đánh dấu trong một đối tượng (this.markers
) bằng cách sử dụng Mã địa điểm làm khoá. Đây là cách quản lý điểm đánh dấu và sẽ giúp chúng ta tìm thấy một điểm đánh dấu cụ thể sau này.
Cuối cùng, chúng ta cần gọi clearMarkers()
khi bắt đầu mỗi lượt tìm kiếm mới. Nơi tốt nhất cho việc này là bên trong performSearch
.
Cập nhật tệp JavaScript
Cập nhật tệp script.js
bằng các phương thức mới và những thay đổi đối với attachEventListeners
và 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();
});
Kiểm tra bài tập
Lưu tệp và làm mới trang trong trình duyệt. Nhấp vào nút "Tìm kiếm".
Vòng quay tải sẽ xuất hiện trong giây lát rồi biến mất. Thanh bên sẽ hiển thị danh sách các địa điểm có liên quan đến cụm từ tìm kiếm và bạn sẽ thấy các điểm đánh dấu tương ứng xuất hiện trên bản đồ. Hiện tại, các điểm đánh dấu chưa thực hiện bất kỳ thao tác nào khi người dùng nhấp vào. Chúng ta sẽ thêm tính năng tương tác đó trong phần tiếp theo.
6. Kích hoạt bộ lọc tìm kiếm và tính năng tương tác với danh sách
Giờ đây, ứng dụng của chúng ta có thể hiển thị kết quả tìm kiếm, nhưng chưa có tính tương tác. Trong phần này, chúng ta sẽ đưa tất cả các chế độ kiểm soát của người dùng vào hoạt động. Chúng tôi sẽ kích hoạt các bộ lọc, cho phép tìm kiếm bằng phím "Enter" và kết nối các mục trong danh sách kết quả với vị trí tương ứng của chúng trên bản đồ.
Khi kết thúc bước này, ứng dụng sẽ phản hồi đầy đủ đối với dữ liệu đầu vào của người dùng.
Kích hoạt bộ lọc tìm kiếm
Trước tiên, phương thức performSearch
sẽ được cập nhật để đọc các giá trị từ tất cả chế độ kiểm soát bộ lọc trong tiêu đề. Đối với mỗi bộ lọc (giá, điểm xếp hạng và "Đang mở"), thuộc tính tương ứng sẽ được đặt trên đối tượng searchRequest
trước khi thực hiện tìm kiếm.
Thêm trình nghe sự kiện cho tất cả các chế độ kiểm soát
Tiếp theo, chúng ta sẽ mở rộng phương thức attachEventListeners
. Chúng ta sẽ thêm trình nghe cho sự kiện change
trên mỗi chế độ kiểm soát bộ lọc, cũng như trình nghe keydown
trên đầu vào tìm kiếm để phát hiện thời điểm người dùng nhấn phím "Enter". Tất cả trình nghe mới này sẽ gọi phương thức performSearch
.
Kết nối danh sách kết quả với bản đồ
Để mang lại trải nghiệm liền mạch, khi người dùng nhấp vào một mục trong danh sách kết quả ở thanh bên, bản đồ sẽ tập trung vào vị trí đó.
Một phương thức mới là handleResultClick
sẽ theo dõi sự kiện gmp-select
. Sự kiện này được kích hoạt bởi Phần tử tìm kiếm địa điểm khi người dùng nhấp vào một mục. Hàm này sẽ tìm vị trí của địa điểm được liên kết và di chuyển bản đồ một cách mượt mà đến vị trí đó.
Để điều này hoạt động, hãy đảm bảo rằng thuộc tính selectable
có trên thành phần gmp-place-search
trong 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>
Cập nhật tệp JavaScript
Cập nhật tệp script.js
bằng đoạn mã hoàn chỉnh sau. Phiên bản này bao gồm phương thức handleResultClick
mới và logic được cập nhật trong attachEventListeners
và 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();
});
Kiểm tra bài tập
Lưu tệp script.js
rồi làm mới trang. Lúc này, ứng dụng sẽ có tính tương tác cao.
Xác minh những điều sau:
- Bạn có thể tìm kiếm bằng cách nhấn phím "Enter" trong hộp tìm kiếm.
- Việc thay đổi bất kỳ bộ lọc nào (Giá, Điểm xếp hạng, Đang mở) sẽ kích hoạt một lượt tìm kiếm mới và cập nhật kết quả.
- Khi bạn nhấp vào một mục trong danh sách kết quả ở thanh bên, bản đồ sẽ chuyển động mượt mà đến vị trí của mục đó.
Trong phần tiếp theo, chúng ta sẽ triển khai thẻ thông tin chi tiết xuất hiện khi người dùng nhấp vào một điểm đánh dấu.
7. Triển khai phần tử Place Details
Ứng dụng của chúng ta hiện đã hoàn toàn có tính tương tác, nhưng vẫn thiếu một tính năng chính: khả năng xem thêm thông tin về một địa điểm đã chọn. Trong phần này, chúng ta sẽ triển khai Phần tử thông tin chi tiết về địa điểm. Phần tử này sẽ xuất hiện khi người dùng nhấp vào một điểm đánh dấu trên bản đồ hoặc chọn một mục trong Phần tử tìm kiếm địa điểm.
Tạo một vùng chứa thẻ chi tiết có thể sử dụng lại
Cách hiệu quả nhất để hiển thị thông tin chi tiết về địa điểm trên bản đồ là tạo một vùng chứa duy nhất có thể dùng lại. Chúng ta sẽ dùng một AdvancedMarkerElement
làm vùng chứa này. Nội dung của tiện ích này sẽ là tiện ích gmp-place-details-compact
bị ẩn mà chúng ta đã có trong index.html
.
Một phương thức mới, initDetailsPopup
, sẽ xử lý việc tạo điểm đánh dấu có thể dùng lại này. Thẻ này sẽ được tạo một lần khi ứng dụng tải và sẽ bắt đầu ở trạng thái ẩn. Chúng ta cũng sẽ thêm một trình nghe vào bản đồ chính theo phương thức này, để khi nhấp vào vị trí bất kỳ trên bản đồ, thẻ chi tiết sẽ ẩn đi.
Cập nhật hành vi nhấp vào điểm đánh dấu
Tiếp theo, chúng ta cần cập nhật những gì sẽ xảy ra khi người dùng nhấp vào một điểm đánh dấu vị trí. Giờ đây, trình nghe 'click'
bên trong phương thức addMarkers
sẽ chịu trách nhiệm hiển thị thẻ chi tiết.
Khi người dùng nhấp vào một điểm đánh dấu, trình nghe sẽ:
- Di chuyển bản đồ đến vị trí của điểm đánh dấu.
- Cập nhật thẻ chi tiết bằng thông tin về địa điểm cụ thể đó.
- Đặt thẻ chi tiết tại vị trí của điểm đánh dấu và hiển thị thẻ đó.
Kết nối lượt nhấp vào danh sách với lượt nhấp vào điểm đánh dấu
Cuối cùng, chúng ta sẽ cập nhật phương thức handleResultClick
. Thay vì chỉ di chuyển bản đồ, giờ đây, thao tác này sẽ kích hoạt sự kiện click
theo phương thức lập trình trên điểm đánh dấu tương ứng. Đây là một mẫu mạnh mẽ cho phép chúng ta sử dụng lại chính xác cùng một logic cho cả hai hoạt động tương tác, giúp mã của chúng ta luôn gọn gàng và dễ bảo trì.
Cập nhật tệp JavaScript
Cập nhật tệp script.js
bằng đoạn mã sau. Các phần mới hoặc có thay đổi là phương thức initDetailsPopup
và các phương thức addMarkers
và handleResultClick
đã được cập nhật.
// 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();
});
Kiểm tra bài tập
Lưu tệp script.js
rồi làm mới trang. Giờ đây, ứng dụng sẽ hiển thị thông tin chi tiết theo yêu cầu.
Xác minh những điều sau:
- Khi bạn nhấp vào một điểm đánh dấu trên bản đồ, bản đồ sẽ chuyển đến vị trí trung tâm và mở một thẻ chi tiết có kiểu dáng bên trên điểm đánh dấu.
- Thao tác nhấp vào một mục trong danh sách kết quả trên thanh bên cũng sẽ cho ra kết quả tương tự.
- Khi bạn nhấp vào bản đồ bên ngoài thẻ, thẻ sẽ đóng.
- Khi bạn bắt đầu một lượt tìm kiếm mới, mọi thẻ chi tiết đang mở cũng sẽ đóng.
8. Thêm bước hoàn thiện cuối cùng
Giờ đây, ứng dụng của chúng ta đã hoạt động đầy đủ, nhưng chúng ta có thể thêm một vài điểm hoàn thiện cuối cùng để cải thiện hơn nữa trải nghiệm người dùng. Trong phần cuối cùng này, chúng ta sẽ triển khai hai tính năng chính: tiêu đề động cung cấp ngữ cảnh phù hợp hơn cho kết quả tìm kiếm và định dạng tự động cho cụm từ tìm kiếm của người dùng.
Tạo tiêu đề kết quả động
Hiện tại, tiêu đề thanh bên luôn là "Kết quả". Chúng ta có thể cung cấp nhiều thông tin hơn bằng cách cập nhật để phản ánh nội dung tìm kiếm hiện tại. Ví dụ: "Bánh kẹp gần New York".
Để làm việc này, chúng ta sẽ sử dụng Geocoding API để chuyển đổi toạ độ tâm của bản đồ thành một vị trí mà con người có thể đọc được, chẳng hạn như tên thành phố. Một phương thức async
mới, updateResultsHeader
, sẽ xử lý logic này. Phương thức này sẽ được gọi mỗi khi một cụm từ tìm kiếm được thực hiện.
Định dạng cụm từ tìm kiếm của người dùng
Để đảm bảo giao diện người dùng trông gọn gàng và nhất quán, chúng tôi sẽ tự động định dạng cụm từ tìm kiếm của người dùng thành "Chữ hoa đầu từ" (ví dụ: "burger restaurant" trở thành "Burger Restaurant"). Một hàm trợ giúp, toTitleCase
, sẽ xử lý quá trình chuyển đổi này. Phương thức performSearch
sẽ được cập nhật để sử dụng hàm này cho dữ liệu đầu vào của người dùng trước khi thực hiện tìm kiếm và cập nhật tiêu đề.
Cập nhật tệp JavaScript
Cập nhật tệp script.js
bằng phiên bản mã cuối cùng. Trong đó có các phương thức toTitleCase
và updateResultsHeader
mới, cũng như phương thức performSearch
đã cập nhật để tích hợp các phương thức này.
// 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();
});
Kiểm tra bài tập
Lưu tệp script.js
rồi làm mới trang.
Xác minh các tính năng:
- Nhập
pizza
(tất cả đều là chữ thường) vào hộp tìm kiếm rồi nhấp vào biểu tượng tìm kiếm. Văn bản trong hộp sẽ thay đổi thành "Pizza" và tiêu đề trong thanh bên sẽ cập nhật thành "Pizza gần New York". - Di chuyển bản đồ đến một thành phố khác, chẳng hạn như Boston, rồi tìm kiếm lại. Tiêu đề sẽ cập nhật thành "Pizza gần Boston".
9. Xin chúc mừng
Bạn đã tạo thành công một ứng dụng tìm kiếm địa phương hoàn chỉnh, có tính tương tác, kết hợp sự đơn giản của Places UI Kit với sức mạnh của các API JavaScript cốt lõi của Nền tảng Google Maps.
Kiến thức bạn học được
- Cách cấu trúc một ứng dụng lập bản đồ bằng cách sử dụng lớp JavaScript để quản lý trạng thái và logic.
- Cách sử dụng Places UI Kit với API JavaScript của Google Maps để phát triển giao diện người dùng một cách nhanh chóng.
- Cách thêm và quản lý Advanced Markers theo phương thức lập trình để hiển thị các điểm đến ưa thích tuỳ chỉnh trên bản đồ.
- Cách sử dụng Dịch vụ mã hoá địa lý để chuyển đổi toạ độ thành địa chỉ dễ đọc nhằm mang lại trải nghiệm người dùng tốt hơn.
- Cách xác định và khắc phục các điều kiện xung đột thường gặp trong một ứng dụng tương tác bằng cách sử dụng cờ trạng thái và đảm bảo các thuộc tính thành phần được cập nhật đúng cách.
Tiếp theo là gì?
- Tìm hiểu thêm về cách Tuỳ chỉnh điểm đánh dấu nâng cao bằng cách thay đổi màu sắc, tỷ lệ hoặc thậm chí sử dụng HTML tuỳ chỉnh.
- Khám phá tính năng Định kiểu bản đồ dựa trên đám mây để tuỳ chỉnh giao diện của bản đồ cho phù hợp với thương hiệu của bạn.
- Hãy thử thêm Thư viện vẽ để cho phép người dùng vẽ các hình dạng trên bản đồ nhằm xác định khu vực tìm kiếm.
- Hãy giúp chúng tôi tạo ra nội dung hữu ích nhất cho bạn bằng cách trả lời khảo sát sau:
Bạn muốn xem những lớp học lập trình nào khác?
Bạn không tìm thấy lớp học lập trình mà mình quan tâm nhất? Yêu cầu cấp lại bằng cách báo lỗi mới tại đây.