‘use strict’;
/** Hides a DOM element and optionally focuses on focusEl. */
function hideElement(el, focusEl) {
el.style.display = ‘none’;
if (focusEl) focusEl.focus();
}
/** Shows a DOM element that has been hidden and optionally focuses on focusEl. */
function showElement(el, focusEl) {
el.style.display = ‘block’;
if (focusEl) focusEl.focus();
}
/** Determines if a DOM element contains content that cannot be scrolled into view. */
function hasHiddenContent(el) {
const noscroll = window.getComputedStyle(el).overflowY.includes(‘hidden’);
return noscroll && el.scrollHeight > el.clientHeight;
}
/** Format a Place Type string by capitalizing and replacing underscores with spaces. */
function formatPlaceType(str) {
const capitalized = str.charAt(0).toUpperCase() + str.slice(1);
return capitalized.replace(/_/g, ‘ ‘);
}
/** Number of POIs to show on widget load. */
const ND_NUM_PLACES_INITIAL = 5;
/** Number of additional POIs to show when ‘Show More’ button is clicked. */
const ND_NUM_PLACES_SHOW_MORE = 5;
/** Maximum number of place photos to show on the details panel. */
const ND_NUM_PLACE_PHOTOS_MAX = 6;
/** Minimum zoom level at which the default map POI pins will be shown. */
const ND_DEFAULT_POI_MIN_ZOOM = 18;
/** Mapping of Place Types to Material Icons used to render custom map markers. */
const ND_MARKER_ICONS_BY_TYPE = {
// Full list of icons can be found at https://fonts.google.com/icons
‘_default’: ‘circle’,
};
/**
* Defines an instance of the Neighborhood Discovery widget, to be
* instantiated when the Maps library is loaded.
*/
function NeighborhoodDiscovery(configuration) {
const widget = this;
const widgetEl = document.querySelector(‘.neighborhood-discovery’);
widget.center = configuration.mapOptions.center;
widget.places = configuration.pois || [];
// Initialize core functionalities ————————————-
initializeMap();
initializePlaceDetails();
initializeSidePanel();
// Initialize additional capabilities ———————————-
// Initializer function definitions ————————————
/** Initializes the interactive map and adds place markers. */
function initializeMap() {
const mapOptions = configuration.mapOptions;
widget.mapBounds = new google.maps.Circle(
{center: widget.center, radius: configuration.mapRadius}).getBounds();
mapOptions.restriction = {latLngBounds: widget.mapBounds};
mapOptions.mapTypeControlOptions = {position: google.maps.ControlPosition.TOP_RIGHT};
widget.map = new google.maps.Map(widgetEl.querySelector(‘.map’), mapOptions);
widget.map.fitBounds(widget.mapBounds, /* padding= */ 0);
widget.map.addListener(‘click’, (e) => {
// Check if user clicks on a POI pin from the base map.
if (e.placeId) {
e.stop();
widget.selectPlaceById(e.placeId);
}
});
widget.map.addListener(‘zoom_changed’, () => {
// Customize map styling to show/hide default POI pins or text based on zoom level.
const hideDefaultPoiPins = widget.map.getZoom() < ND_DEFAULT_POI_MIN_ZOOM;
widget.map.setOptions({
styles: [{
featureType: 'poi',
elementType: hideDefaultPoiPins ? 'labels' : 'labels.text',
stylers: [{visibility: 'off'}],
}],
});
});
const markerPath = widgetEl.querySelector('.marker-pin path').getAttribute('d');
const drawMarker = function(title, position, fillColor, strokeColor, labelText) {
return new google.maps.Marker({
title: title,
position: position,
map: widget.map,
icon: {
path: markerPath,
fillColor: fillColor,
fillOpacity: 1,
strokeColor: strokeColor,
anchor: new google.maps.Point(13, 35),
labelOrigin: new google.maps.Point(13, 13),
},
label: {
text: labelText,
color: 'white',
fontSize: '16px',
fontFamily: 'Material Icons',
},
});
};
// Add marker for the specified Place object.
widget.addPlaceMarker = function(place) {
place.marker = drawMarker(place.name, place.coords, '#EA4335', '#C5221F', place.icon);
place.marker.addListener('click', () => void widget.selectPlaceById(place.placeId));
};
// Fit map to bounds that contain all markers of the specified Place objects.
widget.updateBounds = function(places) {
const bounds = new google.maps.LatLngBounds();
bounds.extend(widget.center);
for (let place of places) {
bounds.extend(place.marker.getPosition());
}
widget.map.fitBounds(bounds, /* padding= */ 100);
};
// Marker used to highlight a place from Autocomplete search.
widget.selectedPlaceMarker = new google.maps.Marker({title: ‘Point of Interest’});
}
/** Initializes Place Details service for the widget. */
function initializePlaceDetails() {
const detailsService = new google.maps.places.PlacesService(widget.map);
const placeIdsToDetails = new Map(); // Create object to hold Place results.
for (let place of widget.places) {
placeIdsToDetails.set(place.placeId, place);
place.fetchedFields = new Set([‘place_id’]);
}
widget.fetchPlaceDetails = function(placeId, fields, callback) {
if (!placeId || !fields) return;
// Check for field existence in Place object.
let place = placeIdsToDetails.get(placeId);
if (!place) {
place = {placeId: placeId, fetchedFields: new Set([‘place_id’])};
placeIdsToDetails.set(placeId, place);
}
const missingFields = fields.filter((field) => !place.fetchedFields.has(field));
if (missingFields.length === 0) {
callback(place);
return;
}
const request = {placeId: placeId, fields: missingFields};
let retryCount = 0;
const processResult = function(result, status) {
if (status !== google.maps.places.PlacesServiceStatus.OK) {
// If query limit has been reached, wait before making another call;
// Increase wait time of each successive retry with exponential backoff
// and terminate after five failed attempts.
if (status === google.maps.places.PlacesServiceStatus.OVER_QUERY_LIMIT &&
retryCount < 5) {
const delay = (Math.pow(2, retryCount) + Math.random()) * 500;
setTimeout(() => void detailsService.getDetails(request, processResult), delay);
retryCount++;
}
return;
}
// Basic details.
if (result.name) place.name = result.name;
if (result.geometry) place.coords = result.geometry.location;
if (result.formatted_address) place.address = result.formatted_address;
if (result.photos) {
place.photos = result.photos.map((photo) => ({
urlSmall: photo.getUrl({maxWidth: 200, maxHeight: 200}),
urlLarge: photo.getUrl({maxWidth: 1200, maxHeight: 1200}),
attrs: photo.html_attributions,
})).slice(0, ND_NUM_PLACE_PHOTOS_MAX);
}
if (result.types) {
place.type = formatPlaceType(result.types[0]);
place.icon = ND_MARKER_ICONS_BY_TYPE[‘_default’];
for (let type of result.types) {
if (type in ND_MARKER_ICONS_BY_TYPE) {
place.type = formatPlaceType(type);
place.icon = ND_MARKER_ICONS_BY_TYPE[type];
break;
}
}
}
if (result.url) place.url = result.url;
for (let field of missingFields) {
place.fetchedFields.add(field);
}
callback(place);
};
detailsService.getDetails(request, processResult);
};
}
/** Initializes the side panel that holds curated POI results. */
function initializeSidePanel() {
const placesPanelEl = widgetEl.querySelector(‘.places-panel’);
const detailsPanelEl = widgetEl.querySelector(‘.details-panel’);
const placeResultsEl = widgetEl.querySelector(‘.place-results-list’);
const showMoreButtonEl = widgetEl.querySelector(‘.show-more-button’);
const photoModalEl = widgetEl.querySelector(‘.photo-modal’);
const detailsTemplate = Handlebars.compile(
document.getElementById(‘nd-place-details-tmpl’).innerHTML);
const resultsTemplate = Handlebars.compile(
document.getElementById(‘nd-place-results-tmpl’).innerHTML);
// Show specified POI photo in a modal.
const showPhotoModal = function(photo, placeName) {
const prevFocusEl = document.activeElement;
const imgEl = photoModalEl.querySelector(‘img’);
imgEl.src = photo.urlLarge;
const backButtonEl = photoModalEl.querySelector(‘.back-button’);
backButtonEl.addEventListener(‘click’, () => {
hideElement(photoModalEl, prevFocusEl);
imgEl.src = ”;
});
photoModalEl.querySelector(‘.photo-place’).innerHTML = placeName;
photoModalEl.querySelector(‘.photo-attrs span’).innerHTML = photo.attrs;
const attributionEl = photoModalEl.querySelector(‘.photo-attrs a’);
if (attributionEl) attributionEl.setAttribute(‘target’, ‘_blank’);
photoModalEl.addEventListener(‘click’, (e) => {
if (e.target === photoModalEl) {
hideElement(photoModalEl, prevFocusEl);
imgEl.src = ”;
}
});
showElement(photoModalEl, backButtonEl);
};
// Select a place by id and show details.
let selectedPlaceId;
widget.selectPlaceById = function(placeId, panToMarker) {
if (selectedPlaceId === placeId) return;
selectedPlaceId = placeId;
const prevFocusEl = document.activeElement;
const showDetailsPanel = function(place) {
detailsPanelEl.innerHTML = detailsTemplate(place);
const backButtonEl = detailsPanelEl.querySelector(‘.back-button’);
backButtonEl.addEventListener(‘click’, () => {
hideElement(detailsPanelEl, prevFocusEl);
selectedPlaceId = undefined;
widget.selectedPlaceMarker.setMap(null);
});
detailsPanelEl.querySelectorAll(‘.photo’).forEach((photoEl, i) => {
photoEl.addEventListener(‘click’, () => {
showPhotoModal(place.photos[i], place.name);
});
});
showElement(detailsPanelEl, backButtonEl);
detailsPanelEl.scrollTop = 0;
};
const processResult = function(place) {
if (place.marker) {
widget.selectedPlaceMarker.setMap(null);
} else {
widget.selectedPlaceMarker.setPosition(place.coords);
widget.selectedPlaceMarker.setMap(widget.map);
}
if (panToMarker) {
widget.map.panTo(place.coords);
}
showDetailsPanel(place);
};
widget.fetchPlaceDetails(placeId, [
‘name’, ‘types’, ‘geometry.location’, ‘formatted_address’, ‘photo’, ‘url’,
], processResult);
};
// Render the specified place objects and append them to the POI list.
const renderPlaceResults = function(places, startIndex) {
placeResultsEl.insertAdjacentHTML(‘beforeend’, resultsTemplate({places: places}));
placeResultsEl.querySelectorAll(‘.place-result’).forEach((resultEl, i) => {
const place = places[i – startIndex];
if (!place) return;
// Clicking anywhere on the item selects the place.
// Additionally, create a button element to make this behavior
// accessible under tab navigation.
resultEl.addEventListener(‘click’, () => {
widget.selectPlaceById(place.placeId, /* panToMarker= */ true);
});
resultEl.querySelector(‘.name’).addEventListener(‘click’, (e) => {
widget.selectPlaceById(place.placeId, /* panToMarker= */ true);
e.stopPropagation();
});
widget.addPlaceMarker(place);
});
};
// Index of next Place object to show in the POI list.
let nextPlaceIndex = 0;
// Fetch and show basic info for the next N places.
const showNextPlaces = function(n) {
const nextPlaces = widget.places.slice(nextPlaceIndex, nextPlaceIndex + n);
if (nextPlaces.length < 1) {
hideElement(showMoreButtonEl);
return;
}
showMoreButtonEl.disabled = true;
// Keep track of the number of Places calls that have not finished.
let count = nextPlaces.length;
for (let place of nextPlaces) {
const processResult = function(place) {
count--;
if (count > 0) return;
renderPlaceResults(nextPlaces, nextPlaceIndex);
nextPlaceIndex += n;
widget.updateBounds(widget.places.slice(0, nextPlaceIndex));
const hasMorePlacesToShow = nextPlaceIndex < widget.places.length;
if (hasMorePlacesToShow || hasHiddenContent(placesPanelEl)) {
showElement(showMoreButtonEl);
showMoreButtonEl.disabled = false;
} else {
hideElement(showMoreButtonEl);
}
};
widget.fetchPlaceDetails(place.placeId, [
'name', 'types', 'geometry.location',
], processResult);
}
};
showNextPlaces(ND_NUM_PLACES_INITIAL);
showMoreButtonEl.addEventListener('click', () => {
placesPanelEl.classList.remove(‘no-scroll’);
showMoreButtonEl.classList.remove(‘sticky’);
showNextPlaces(ND_NUM_PLACES_SHOW_MORE);
});
}
}