Home Reference Source

src/map.js

import CleanupLocation from './cleanup_location';
import MarkerClusterer from 'marker-clusterer-plus';
import createBrowserHistory from 'history/createBrowserHistory';
import axios from 'axios';
import { mapOptions, MARKER_ICON_PATH, CLUSTER_MARKER_ICON_PATH, WORDPRESS_URL } from 'map_config';
let headerTemplate = require('./header.handlebars');
let searchTemplate = require('./search.handlebars');
let popupTemplate = require('./popup.handlebars');
let loadingTemplate = require('./loadingscreen.handlebars');
let InfoBox;

/**
 * Free Seas Map
 * @example
 * let config = {
 *  element: document.getElementById('map'),
 *  searchBoxElement: document.getElementById('mapAutocomplete'),
 *  google: window.google,
 *  wordpressUrl: 'https://example-site/wp-json/wp/v2/tfs_map/'
 * }
 *
 * window.freeSeasMap = new freeSeasMap(config);
 */
export default class Map {
  /**
   * Creates an instance of Map.
   *
   * @emits {map-loaded} emit event when google map has loaded
   * @emits {map-loading-locations} emit event when loading locations
   * @emits {map-cluster-rendered} emit event when cluster items have rendered [good for showing a loading window]
   * @emits {map-idled} emit event when map has idled [good for grabbing updates on the locations on screen]
   * @emits {map-place-changed} emit event when place has changed via auto complete search
   * @emits {marker-clicked} emit event when map marker is clicked and data with that marker
   * @emits {map-url-location} emit event when location_id is present and found as a valid location in the url
   * @emits {map-info-window-closed} emit event when an info window is closed
   *
   * @param {Object} config - configuration
   * @param {Object} config.element - document.getElementById("elementId") where to render the map
   * @param {string} config.mapHeight - set the css height of the map default is 100vh
   * @param {string} config.mapWidth - set the css width of the map default is 100vw
   * @param {Object} config.google - google maps instance usually "window.google"
   * @param {Object} config.searchBoxElement - document.getElementById("elementId") where to render the search bar
   * @param {Object} config.searchBoxClickElement - document.getElementById("elementId") for element when clicked to trigger a search
   * @param {string} config.infoWindowClickHandler - function to be called with the id of the cleanup location when its' info window is clicked
   * @param {string} config.plusZoomControlImage - url for plus zoom control
   * @param {string} config.minusZoomControlImage - url for plus zoom control
   * @param {string} config.wordpressUrl - wordpress url to fetch data from
   * @param {string} config.ocoMapUrl - original ocean conservancy map location
   * @param {string} config.markerPlaceholderImagesUrl - If a location doesn't have an image, use a place holder image, if provided with photo01.png as the filename provide 30 to randomly cycle through in the directory, photo01.jpg~photo30.jpg
   * @param {string} config.markerIconPath - path to individual image (32 x 32px default) for each marker on map
   * @param {string} config.onClickMarkerIconPath - path to individual image (32 x 32px default) for each marker on map when clicked
   * @param {string} config.clusterMarkerIconPath - path to individual image for cluster icons (64px x 64px default) on map
   * @param {Object} config.customMapOptions - standard google maps options object, center, zoom, styles, etc @see https://developers.google.com/maps/documentation/javascript/reference/3.exp/map#Map
   * @param {string} config.gestureHandling - Define the gesture behavior, default is "greedy" to allow scrolling without CTRL + Wheel or CMD + Wheel
   * @param {Object} config.customClusterStyles - standard google cluster icon styles, use size, shape for images etc @see http://htmlpreview.github.io/?https://github.com/googlemaps/v3-utility-library/blob/master/markerclusterer/docs/reference.html
   * @return {class} instance of Map
   *
   * @memberof Map
   */

  constructor(config) {
    /**
     * DOM node to render map to
     * @type {HTMLElement}
     */
    this._element = config.element;

    /**
     * Map height, default is 100vh
     * @type {string}
     */
    this._mapHeight = config.mapHeight;

    /**
     * Map width, default is 100vw
     * @type {string}
     */
    this._mapWidth = config.mapWidth;
    /**
     * Map width, default is 100vw
     * @type {string}
     */

    /**
     * Google Maps SDK Instance
     * @type {<google>}
     */
    this._google = config.google;

    /**
     * DOM node to place the places searchBox, should be of type input
     * @type {HTMLElement}
     */
    this._searchBoxElement = config.searchBoxElement;

    /**
     * DOM node to act as a click handler to fire search events on the searchBoxElement, button, div, span, etc
     * @type {HTMLElement}
     */
    this._searchBoxClickElement = config.searchBoxClickElement;

    /**
     * Function name to be called with the id of the cleanup location when its' info window is clicked, should be global
     * @type {string}
     */
    this._infoWindowClickHandler = config.infoWindowClickHandler;

    /**
     * URL path of image to use as the plus zoom control background image
     * @type {string}
     */
    this._plusZoomControlImage = config.plusZoomControlImage;

    /**
     * URL path of image to use as the minus zoom control background image
     * @type {string}
     */
    this._minusZoomControlImage = config.minusZoomControlImage;

    /**
     * URL path of the cleanup location data, @see https://oceanconservancy.org/
     * wp-json/wp/v2/tfs_map/ is inferred as the wordpress api
     * @type {string}
     */
    this._wordpressUrl = config.wordpressUrl ? config.wordpressUrl : WORDPRESS_URL;

    /**
     * URL path of the Ocean Conservancy Map, defaults to hash links if none provided
     *
     * @type {string}
     */
    this._ocoMapUrl = config.ocoMapUrl || '#';

    /**
     * URL path of the marker placeholder image, this are images to be displayed if a cleanup location doesn't have an image attached
     * @type {string}
     */
    this._markerPlaceholderImagesUrl = config.markerPlaceholderImagesUrl;

    /**
     * URL path of the marker icon that identify cleanup locations on the map, a 32px x 32px png or SVG is best
     * @type {string}
     */
    this._markerIconPath = config.markerIconPath ? config.markerIconPath : MARKER_ICON_PATH;

    /**
     * URL path of the cluster marker icon that identify multiple grouped cleanup locations on the map, a 64px x 64px png or SVG is best
     * @type {string}
     */
    this._clusterMarkerIconPath = config.clusterMarkerIconPath ? config.clusterMarkerIconPath : CLUSTER_MARKER_ICON_PATH;

    /**
     * URL path of the marker icon that changes when a marker is clicked, a 32px x 32px png or SVG is best
     * @type {string}
     */
    this._onClickMarkerIconPath = config.onClickMarkerIconPath;

    /**
     * A configuration object for defining google map related items
     * @type {<google.maps.MapOptions>}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3/map#MapOptions
     */
    this._customMapOptions = config.customMapOptions ? Object.assign(mapOptions, config.customMapOptions) : mapOptions;

    /**
    * Embedded
    * @type {boolean}
    */
    this._embedded = config.embedded;

    /**
     * This setting controls how the API handles gestures on the map, default is greedy
     * @type {string}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3/map#MapOptions.gestureHandling
     */
    this._gestureHandling = config.gestureHandling ? config.gestureHandling : 'greedy';

    /**
     * Cluster styles default for cluster markers
     * @type {Array}
     * @see http://htmlpreview.github.io/?https://github.com/googlemaps/v3-utility-library/blob/master/markerclustererplus/docs/reference.html
     */
    this._clusterStyles = [
      {
        textColor: 'white',
        url: this._clusterMarkerIconPath,
        height: 64,
        width: 64
      },
      {
        textColor: 'white',
        url: this._clusterMarkerIconPath,
        height: 64,
        width: 64
      },
      {
        textColor: 'white',
        url: this._clusterMarkerIconPath,
        height: 64,
        width: 64
      }
    ];

    /**
     * Merges the custom cluster styles with configured styles
     * @type {Object}
     * @see http://htmlpreview.github.io/?https://github.com/googlemaps/v3-utility-library/blob/master/markerclustererplus/docs/reference.html
     */
    this._customClusterStyles = config.customClusterStyles ? config.customClusterStyles : this._clusterStyles;

    /**
     * Sets the MarkerClusterer to be used as a utility method to create marker clusters
     * @type {<MarkerClusterer>}
     * @see http://htmlpreview.github.io/?https://github.com/googlemaps/v3-utility-library/blob/master/markerclustererplus/docs/reference.html
     */
    this._markerClusterer = MarkerClusterer;

    /**
     * Google Maps Utility
     * @type {<google.maps.Map>}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3/map
     */
    this._map = null;

    /**
     * Google Maps Utility LatLngBounds, holds the last known valid center of the map
     * @type {<google.maps.LatLngBounds>}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3/coordinates#LatLngBounds
     */
    this._lastValidCenter = null;

    /**
     * SearchBox element placeholder
     * @type {HTMLElement}
     */
    this._searchBox = null;

    /**
     * Active Marker that has been clicked on or hovered on the map
     * @type {<google.maps.Marker>}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3.exp/marker
     */
    this._activeMarker = null;

    /**
     * Markers that have been fetched from the cleanup location database
     * @type {<CleanupLocation[]>}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3.exp/marker
     */
    this._markers = [];

    /**
     * Visible markers that have been fetched from the cleanup location database in the current map view
     * @type {<CleanupLocation[]>}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3.exp/marker
     */
    this._visibleMarkers = [];

    /**
     * Holds value if a search has been preformed
     * @type {boolean}
     */
    this._searchHistoryChanged = true;

    /**
     * Google Maps Info Window or InfoBox
     * @type {<google.maps.InfoWindow>}
     * @see https://developers.google.com/maps/documentation/javascript/reference/3.exp/marker
     * @see https://github.com/googlemaps/v3-utility-library/tree/master/infobox
     */
    this._infoWindow = null;

    /**
     * Max zoom level of the map
     * @type {number}
     */
    this._maxZoom = this._customMapOptions.maxZoom;

    /**
     * Min zoom level of the map
     * @type {number}
     */
    this._minZoom = this._customMapOptions.minZoom;

    this._widgetPopupHandler = this._widgetPopupHandler.bind(this);
    this._existingEmbedLocationHandler = this._existingEmbedLocationHandler.bind(this);
  }

  /**
   * Fetches all locations from the wordpress url and places them in clusters on the map.
   * Only viable markers are placed for performance reasons.
   *
   * @public
   *
   * @returns {undefined}
   * @memberof Map
   */
  fetchAllLocations() {
    return new Promise((resolve, reject) => {
      let locationRequests = [];

      let options = {
        page: 1,
        per_page: 99, // eslint-disable-line camelcase
        totalPages: 1
      };

      // get initial page number count
      let requestOptions = {
        method: 'GET',
        baseURL: `${this._wordpressUrl}/wp-json/wp/v2/tfs_map/`,
        responseType: 'json',
        params: {
          per_page: options.per_page, // eslint-disable-line camelcase
          page: options.page
        }
      };

      let pageCountRequest = axios.request(requestOptions);

      pageCountRequest
        .then(response => {
          options.totalPages = response.headers['x-wp-totalpages'];
          for (let i = 1; i <= options.totalPages; i++) {
            options.page = i;
            locationRequests.push(this._fetchLocations(options));
          }
          return axios.all(locationRequests);
        })
        .then(responses => {
          return responses.concat.apply([], responses);
        })
        .then(data => {
          resolve(data);
        })
        .catch(error => {
          console.error(error);
        });
    });
  }
  /**
   * Fetches batch of locations from wordpress url
   * @private
   *
   * @param {Object} options
   * @param {number} options.per_page - number of items to fetch
   * @param {number} options.page - page number
   * @returns {Promise<AxiosResponse[], AxiosError>}
   * @memberof Map
   */
  _fetchLocations(options) {
    return new Promise((resolve, reject) => {
      let requestOptions = {
        method: 'GET',
        baseURL: `${this._wordpressUrl}/wp-json/wp/v2/tfs_map/`,
        responseType: 'json',
        params: {
          per_page: options.per_page, // eslint-disable-line camelcase
          page: options.page
        }
      };

      axios
        .request(requestOptions)
        .then(response => {
          resolve(response.data);
        })
        .catch(error => {
          console.error(error);
        });
    });
  }
  /**
   * Method to fetch and set markers, util helper method
   * @private
   *
   * @memberof Map
   */
  _setMarkers() {
    this._emitEvent('map-loading-locations');
    this.fetchAllLocations()
      .then(cleanupLocations => {
        this._markers = this._setAllMarkers(cleanupLocations);
        this._visibleMarkers = this._setAllVisibleMarkers(this._markers);
        this._renderCluster(() => {
          this._emitExistingLocation();
          this._emitExistingSearch();
        });
      })
      .catch(error => {
        console.error(error);
      });
  }
  /**
   * Sets all markers on the map
   * @private
   *
   * @param {any} locations - Wordpress Locations
   * @param {<Map>} map - Active Google Map instance
   * @returns {<Marker[]>} - Array of Google Map Marker
   * @memberof Map
   */
  _setAllMarkers(locations, map) {
    this._markers = [];
    for (let i = 0; i < locations.length; i++) {
      var location = locations[i];

      if (location.latitude && location.longitude) {
        var marker = this._markerFactory(location, this._map);

        this._markers.push(marker);
      }
    }
    return this._markers;
  }
  /**
   * Generate a random string integer between two integers
   * @public
   *
   * @param {integer} start
   * @param {integer} end
   * @returns {string} padded number 01, 09, 10, 11 etc..
   * @memberof Map
   */
  getPaddedRandomNumberBetween(start, end) {
    let randomNumber = Math.floor(Math.random() * end) + start;

    if (randomNumber < 10) {
      randomNumber = `0${randomNumber.toString()}`;
    } else {
      randomNumber.toString();
    }
    return randomNumber;
  }
  /**
   * Renders custom zoom-in-btn as a google map control
   * @private
   *
   * @param {Object} controlDiv - DOM element to attach the zoom control to
   * @param {<Map>} map - Google Map instance
   * @memberof Map
   */
  _zoomInButton(controlDiv, map) {
    let controlWrapper = document.createElement('div');

    controlDiv.appendChild(controlWrapper);

    let zoomInButton = document.createElement('div');

    // Set CSS for the zoomIn
    zoomInButton.className += 'zoom-in-button';
    zoomInButton.style.backgroundImage = `url(${this._plusZoomControlImage})`;

    controlDiv.appendChild(zoomInButton);

    // Setup the click event listener - zoomIn
    this._google.maps.event.addDomListener(zoomInButton, 'click', () => {
      this.increaseZoom();
    });
  }

  /**
   * Renders custom zoom-out-btn as a google map control
   * @private
   *
   * @param {Object} controlDiv - DOM element to attach the zoom control to
   * @param {<Map>} map - Google Map instance
   * @memberof Map
   */
  _zoomOutButton(controlDiv, map) {
    let zoomOutButton = document.createElement('div');

    // Set CSS for the zoomOut
    zoomOutButton.className += 'zoom-out-button';
    zoomOutButton.style.backgroundImage = `url(${this._minusZoomControlImage})`;

    controlDiv.appendChild(zoomOutButton);

    // Setup the click event listener - zoomOut
    this._google.maps.event.addDomListener(zoomOutButton, 'click', () => {
      this.decreaseZoom();
    });
  }

  /**
   * Renders custom zoom-slider as a range input on the map
   * @private
   *
   * @param {<Map>} map - Google Map instance
   * @param {number} minZoom - set the min zoom level for the range slider, default is that setup with mapOptions
   * @param {number} maxZoom - set the mx zoom level for the range slider, default is that setup with mapOptions
   * @param {Object} controlDiv - DOM element to attach the zoom control to
   * @memberof Map
   */
  _zoomSlider(map, minZoom = 0, maxZoom = 21, controlDiv) {
    maxZoom = this._maxZoom ? this._maxZoom : maxZoom;
    minZoom = this._minZoom ? this._minZoom : minZoom;

    let slider = document.createElement('input'),
      zoomSlider = controlDiv;

    slider.value = this._map.getZoom();
    slider.setAttribute('min', minZoom);
    slider.setAttribute('max', maxZoom);
    slider.setAttribute('class', 'slider');
    slider.setAttribute('step', '1');
    slider.setAttribute('type', 'range');
    slider.setAttribute('id', 'slide');
    slider.onchange = event => {
      this.updateSlider(event.target.value);
    };

    zoomSlider.appendChild(slider);

    // Updates the slider when the zoom level of the map changes.
    this._map.addListener('zoom_changed', () => {
      let sl = document.getElementById('slide');

      if (sl) {
        if (sl.value !== this._map.getZoom()) {
          sl.value = this._map.getZoom();
        }
      }
    });
  }

  /**
   * Increases map zoom by one level from the current zoom level
   * @public
   * @memberof Map
   */
  increaseZoom() {
    let currentZoom = this._map.getZoom();

    this._map.setZoom(++currentZoom);
  }

  /**
   * Decreases map zoom by one level from the current zoom level
   * @public
   * @memberof Map
   */
  decreaseZoom() {
    let currentZoom = this._map.getZoom();

    this._map.setZoom(--currentZoom);
  }

  /**
   * Update the zoom slider level with the current zoom amount, requires a zoom slider to be rendered on the map
   * @public
   * @memberof Map
   */
  updateSlider(slideAmount) {
    this._map.setZoom(parseInt(slideAmount, 10));
  }

  /**
   * Checks to see if the map is embedded and returns a configuration
   * @private
   *
   * @memberof Map
   * @returns {<google.maps.MapOptions>}
   */
  _shouldCenterOverNorthAmerica() {
    let newCustomMapOptions = {};

    // TODO: Cleanup this logic a bit
    if (this._customMapOptions.center !== undefined) {
      newCustomMapOptions = this._customMapOptions;
      // if we are on a small screen we center over north america
      // even if it was configured over another area unless the zoom is greater than 2
      if (window.matchMedia('screen and (max-width: 768px)').matches) {
        newCustomMapOptions = this._setDefaultMapLocation(39.809734, -98.555620, 3);
      } else {
        newCustomMapOptions = this._customMapOptions;
      }
    } else {
      if (window.matchMedia('screen and (max-width: 768px)').matches) {
        newCustomMapOptions = this._setDefaultMapLocation(39.809734, -98.555620, 3);
      } else {
        // set our default options otherwise
        newCustomMapOptions = this._setDefaultMapLocation(35.623883, -39.459826, 3);
      }
    }

    return newCustomMapOptions;
  }

  /**
   * Sets the default map location based on the, lat, lng, and zoom.
   * Uses the configured map location unless otherwise provided
   * @private
   *
   * @param {number} lat
   * @param {number} lng
   * @param {number} zoom
   *
   * @memberof Map
   *
   * @returns {<google.maps.MapOptions>}
   */
  _setDefaultMapLocation(lat, lng, zoom) {
    let newCustomMapOptions = {};

    newCustomMapOptions.center = this._customMapOptions.center || { lat: lat, lng: lng };
    newCustomMapOptions.zoom = zoom;
    newCustomMapOptions = Object.assign(this._customMapOptions, newCustomMapOptions);
    return newCustomMapOptions;
  }

  /**
   * Renders the google.maps.places.SearchBox with search and places geocoding
   * Migrates the map to the location upon successful geocode
   * @private
   *
   * @param {any} inputElement
   * @memberof Map
   */
  _setSearchBox(inputElement) {
    if (this._infoWindow) {
      this._infoWindow.close();
      this._infoWindow = null;
    }

    this._searchBox = new this._google.maps.places.SearchBox(inputElement);

    this._searchBox.setBounds(this._map.getBounds());

    this._searchBox.addListener('places_changed', () => {
      let places = this._searchBox.getPlaces();

      if (places.length === 0) {
        return;
      }

      let bounds = new this._google.maps.LatLngBounds();

      places.forEach(place => {
        if (!place || !place.geometry) return;

        if (place.geometry.viewport) {
          // Only geocoded places have viewport.
          bounds.union(place.geometry.viewport);
        } else {
          bounds.extend(place.geometry.location);
        }
      });

      let place = places[0];

      let params = new URLSearchParams(location.search.slice(1));

      this._map.fitBounds(bounds);

      let placeId = params.get('place_id');

      if (placeId) {
        if (placeId !== place.id) {
          window.mapHistory.push({
            pathname: '/',
            search: `?search=${this._searchBoxElement.value}&place_id=${place.id}`,
            state: { type: 'search', value: this._searchBoxElement.value, place: place.id }
          });
        }
      } else {
        window.mapHistory.push({
          pathname: '/',
          search: `?search=${this._searchBoxElement.value}&place_id=${place.id}`,
          state: { type: 'search', value: this._searchBoxElement.value, place: place.id }
        });
      }

      var markerSearchResult = this._getVisibleMarkersInBounds(bounds, this._markers);

      this._emitEvent('map-place-changed', markerSearchResult);
    });
  }
  /**
   * Sets the google.maps.event.trigger for focus and keydown on click of an element
   * @private
   *
   * @param {Object} clickElement - DOM Element to be clicked on to trigger focus and keydown event for search
   * @memberof Map
   */
  _setSearchBoxClickElement(clickElement) {
    clickElement.onclick = event => {
      event.preventDefault();
      this._google.maps.event.trigger(this._searchBoxElement, 'focus', {});
      this._google.maps.event.trigger(this._searchBoxElement, 'keydown', { keyCode: 13 });
    };
  }
  /**
   * Event Emiter Helper
   * Helps emit custom events, like loading, markers set, map idling etc
   * @private
   *
   * @param {string} eventName - event name to be used
   * @param {any} data - custom object data for the event
   * @memberof Map
   */
  _emitEvent(eventName, data) {
    let event = null;

    if (window.CustomEvent) {
      event = new CustomEvent(eventName, { detail: data });
    } else {
      event = document.createEvent('CustomEvent');

      event.initCustomEvent(eventName, true, true, data);
    }

    this._element.dispatchEvent(event);
  }

  /**
   * Sets visible markers within map view on the map as a cluster
   * @private
   *
   * @param {array} locations
   * @returns {<Marker[]>} - google.maps.Marker
   * @memberof Map
   */
  _setAllVisibleMarkers(locations) {
    this._visibleMarkers = [];
    let bounds = this._map.getBounds();
    let NE = bounds.getNorthEast();
    let SW = bounds.getSouthWest();

    for (var i = 0; i < locations.length; i++) {
      var location = locations[i];

      if (location.cleanupLocation.latitude() && location.cleanupLocation.longitude()) {
        // Check if a place is within bounds - not best way, explained later
        if (NE.lat() > location.cleanupLocation.latitude() && location.cleanupLocation.latitude() > SW.lat() && (NE.lng() > location.cleanupLocation.longitude() && location.cleanupLocation.longitude() > SW.lng())) {
          this._visibleMarkers.push(location);
        }
      }
    }

    return this._visibleMarkers;
  }

  /**
   * Sets visible markers within map view on the map as a cluster
   * @private
   *
   * @param {<LatLngBounds>} bounds - Google Maps google.maps.LatLngBounds
   * @param {<CleanupLocation[]>} locations - Array of Cleanup locations to filter
   * @returns {<Marker[]>} - google.maps.Marker
   * @memberof Map
   */
  _getVisibleMarkersInBounds(bounds, locations) {
    this._visibleMarkers = [];

    // Magic Numbers
    var expandedBounds = this._expandedBounds(bounds, -500, -500, -500, -500);

    let NE = expandedBounds.getNorthEast();
    let SW = expandedBounds.getSouthWest();

    for (var i = 0; i < locations.length; i++) {
      var location = locations[i];

      if (location.cleanupLocation.latitude() && location.cleanupLocation.longitude()) {
        // Check if a place is within bounds - not best way, explained later
        if (NE.lat() > location.cleanupLocation.latitude() && location.cleanupLocation.latitude() > SW.lat() && (NE.lng() > location.cleanupLocation.longitude() && location.cleanupLocation.longitude() > SW.lng())) {
          this._visibleMarkers.push(location);
        }
      }
    }
    return this._visibleMarkers;
  }

  /**
   * Expands LatLngBounds based on a buffer param, padding is inverse, so positive numbers add inwards vs negative ones add
   * @private
   *
   * @param {<LatLngBounds>} bounds - Google Maps google.maps.LatLngBounds
   * @param {number} nPad - north padding buffer amount to be scaled 2^10
   * @param {number} sPad - south padding buffer amount to be scaled 2^10
   * @param {number} ePad - east padding buffer amount to be scaled 2^10
   * @param {number} wPad - west padding buffer amount to be scaled 2^10
   * @returns {<LatLngBounds>} - padded google.maps.LatLngBounds
   * @memberof Map
   */
  _expandedBounds(bounds, nPad, sPad, ePad, wPad) {
    var SW = bounds.getSouthWest();
    var NE = bounds.getNorthEast();
    var topRight = this._map.getProjection().fromLatLngToPoint(NE);
    var bottomLeft = this._map.getProjection().fromLatLngToPoint(SW);
    var scale = Math.pow(2, 10);

    var SWToPoint = this._map.getProjection().fromLatLngToPoint(SW);
    var SWPoint = new this._google.maps.Point((SWToPoint.x - bottomLeft.x) * scale + wPad, (SWToPoint.y - topRight.y) * scale - sPad);
    var SWWorld = new this._google.maps.Point(SWPoint.x / scale + bottomLeft.x, SWPoint.y / scale + topRight.y);
    var pt1 = this._map.getProjection().fromPointToLatLng(SWWorld);

    var NEToPoint = this._map.getProjection().fromLatLngToPoint(NE);
    var NEPoint = new this._google.maps.Point((NEToPoint.x - bottomLeft.x) * scale - ePad, (NEToPoint.y - topRight.y) * scale + nPad);
    var NEWorld = new this._google.maps.Point(NEPoint.x / scale + bottomLeft.x, NEPoint.y / scale + topRight.y);
    var pt2 = this._map.getProjection().fromPointToLatLng(NEWorld);

    return new this._google.maps.LatLngBounds(pt1, pt2);
  }

  /**
   * Returns visible Google Map Markers with instances of CleanupLocation attached
   * @public
   *
   * @returns {<Marker[]>} - google.maps.Marker
   * @memberof Map
   */
  visibleMarkers() {
    return this._visibleMarkers;
  }
  /**
   * Returns a cleanup location by its id
   * @public
   *
   * @param {integer} id - cleanup location id
   * @returns {<Marker>}
   * @memberof Map
   */
  cleanupLocationById(id) {
    return this._markers.find(marker => {
      return marker.cleanupLocation.id() === id;
    });
  }

  /**
   * Returns all markers in memory on the page with instances of CleanupLocation attached
   * @public
   *
   * @returns {<Marker[]>} - google.maps.Marker
   * @memberof Map
   */
  allMarkers() {
    return this._markers;
  }

  /**
   * Open an default google maps info window by the id of a location
   * @public
   *
   * @param {integer} markerId - Wordpress ID of the location from info.id
   * @returns {Marker} Google Maps Marker
   * @memberof Map
   */
  openInfoWindowById(markerId) {
    let foundMarker = null;

    foundMarker = this._markers.find(location => {
      if ('info' in location) {
        return location.info.id === markerId;
      }
      return undefined;
    });

    if (foundMarker) {
      this._resetMarkerIcon(this._activeMarker);
      this._activeMarker = foundMarker;
      if (this._infoWindow) {
        this._infoWindow.open(this._map, this._activeMarker);
      } else {
        this._infoWindowFactory(this._activeMarker);
        this._infoWindow.open(this._map, this._activeMarker);
      }
    }
    return foundMarker;
  }

  /**
   * Open a custom google maps info window by the id of a location, this method takes a google maps marker instead of an id
   * @public
   *
   * @param {<Marker>} marker - google.maps.Marker object
   * @param {string} contentString - HTML String to be displayed as a InfoBox @see https://github.com/googlemaps/v3-utility-library/tree/master/infobox
   *
   * @memberof Map
   */
  openCustomInfoWindow(marker, contentString) {
    if (marker) {
      // set the active marker back to the standard icon
      this._resetMarkerIcon(this._activeMarker);

      if (this._infoWindow) {
        this._infoWindow.close();
        this._infoWindow = null;
        this._emitEvent('map-info-window-closed');
      }

      this._infoWindow = new InfoBox({
        content: contentString,
        disableAutoPan: false,
        maxWidth: 0,
        alignBottom: false,
        pixelOffset: new this._google.maps.Size(35, -100),
        zIndex: null,
        boxStyle: {
          'box-shadow': '0 0 3px 0 rgba(0,0,0,0.50)',
          opacity: 1,
          zIndex: 999,
          background: '#fff',
          width: 'auto'
        },
        closeBoxMargin: '0px 0px 0px 0px',
        closeBoxURL: '',
        pane: 'floatPane',
        enableEventPropagation: false,
        infoBoxClearance: new this._google.maps.Size(100, 100)
      });

      this._google.maps.event.addListener(this._map, 'click', event => {
        this._resetMarkerIcon(this._activeMarker);

        if (this._infoWindow) {
          this._infoWindow.close();
          this._infoWindow = null;
          this._emitEvent('map-info-window-closed');
        }
      });

      // set the clicked on marker to the clicked on icon
      if (this._onClickMarkerIconPath) {
        let clickedOnImage = {
          url: this._onClickMarkerIconPath,
          // This marker is 20 pixels wide by 32 pixels high.
          size: new this._google.maps.Size(32, 32),
          // The origin for this image is (0, 0).
          origin: new this._google.maps.Point(0, 0),
          // The anchor for this image is the base of the flagpole at (0, 32).
          anchor: new this._google.maps.Point(0, 32),
          // The scaling size
          scaledSize: new this._google.maps.Size(32, 32)
        };

        marker.setIcon(clickedOnImage);
      }

      this._infoWindow.open(this._map, marker);

      try {
        let params = new URLSearchParams(location.search.slice(1));

        let locationId = parseInt(params.get('location_id'), 10);

        if (locationId) {
          if (locationId !== marker.cleanupLocation.id()) {
            window.mapHistory.push({
              pathname: '/',
              search: `?location_id=${marker.cleanupLocation.id()}`,
              state: { type: 'location', locationId: marker.cleanupLocation.id() }
            });
          }
        } else {
          window.mapHistory.push({
            pathname: '/',
            search: `?location_id=${marker.cleanupLocation.id()}`,
            state: { type: 'location', locationId: marker.cleanupLocation.id() }
          });
        }
      } catch (error) {
        console.error(error);
      }

      this._activeMarker = marker;
    } else {
      throw new Error('No google maps marker provided');
    }
  }

  /**
   * Open a custom google maps info window by the id of a location, this method takes a google maps marker instead of an id
   * this method also requires a callback that fires when the google maps dom is ready
   * @public
   *
   * @param {<Marker>} marker - google.maps.Marker object
   * @param {string} contentString - HTML String to be displayed as a InfoBox @see https://github.com/googlemaps/v3-utility-library/tree/master/infobox
   * @param {function} callback - callback to fire once the dom is ready and the info window is displayed
   *
   * @memberof Map
   */
  openCustomInfoWindowAsync(marker, contentString, callback) {
    if (marker) {
      // this._activeMarker = marker;

      this._resetMarkerIcon(this._activeMarker);

      if (this._infoWindow) {
        this._infoWindow.close();
        this._infoWindow = null;
        this._emitEvent('map-info-window-closed');
      }

      this._infoWindow = new InfoBox({
        content: contentString,
        disableAutoPan: false,
        maxWidth: 0,
        alignBottom: false,
        pixelOffset: new this._google.maps.Size(35, -100),
        zIndex: null,
        boxStyle: {
          'box-shadow': '0 0 3px 0 rgba(0,0,0,0.50)',
          opacity: 1,
          zIndex: 999,
          background: '#fff',
          width: 'auto'
        },
        closeBoxMargin: '0px 0px 0px 0px',
        closeBoxURL: '',
        pane: 'floatPane',
        enableEventPropagation: false,
        infoBoxClearance: new this._google.maps.Size(100, 100)
      });

      this._google.maps.event.addListener(this._map, 'click', event => {
        this._resetMarkerIcon(this._activeMarker);

        if (this._infoWindow) {
          this._infoWindow.close();
          this._infoWindow = null;
          this._emitEvent('map-info-window-closed');
        }
      });

      // set the clicked on marker to the clicked on icon

      if (this._onClickMarkerIconPath) {
        let clickedOnImage = {
          url: this._onClickMarkerIconPath,
          // This marker is 20 pixels wide by 32 pixels high.
          size: new this._google.maps.Size(32, 32),
          // The origin for this image is (0, 0).
          origin: new this._google.maps.Point(0, 0),
          // The anchor for this image is the base of the flagpole at (0, 32).
          anchor: new this._google.maps.Point(0, 32),
          // The scaling size
          scaledSize: new this._google.maps.Size(32, 32)
        };

        marker.setIcon(clickedOnImage);
      }

      this._infoWindow.open(this._map, marker);

      try {
        let params = new URLSearchParams(location.search.slice(1));

        let locationId = parseInt(params.get('location_id'), 10);

        if (locationId) {
          if (locationId !== marker.cleanupLocation.id()) {
            window.mapHistory.push({
              pathname: '/',
              search: `?location_id=${marker.cleanupLocation.id()}`,
              state: { type: 'location', locationId: marker.cleanupLocation.id() }
            });
          }
        } else {
          window.mapHistory.push({
            pathname: '/',
            search: `?location_id=${marker.cleanupLocation.id()}`,
            state: { type: 'location', locationId: marker.cleanupLocation.id() }
          });
        }
      } catch (error) {
        console.error(error);
      }

      this._google.maps.event.addListener(this._infoWindow, 'domready', () => {
        callback();
      });
    } else {
      throw new Error('No google maps marker provided');
    }
  }

  /**
   * Resets the active marker onclick image set from the config.onClickMarkerIconPath
   *
   * @private
   * @param {<Marker>} activeMarker - active marker instance or is found from the Map instances activeMarker
   *
   * @memberof Map
   */
  _resetMarkerIcon(activeMarker) {
    if (this._onClickMarkerIconPath && this._activeMarker) {
      let image = {
        url: this._markerIconPath,
        // This marker is 20 pixels wide by 32 pixels high.
        size: new this._google.maps.Size(32, 32),
        // The origin for this image is (0, 0).
        origin: new this._google.maps.Point(0, 0),
        // The anchor for this image is the base of the flagpole at (0, 32).
        anchor: new this._google.maps.Point(0, 32),
        // The scaling size
        scaledSize: new this._google.maps.Size(32, 32)
      };

      activeMarker.setIcon(image);
    }
  }

  /**
   * Closes the active info window if it is open and exists
   *
   * @public
   *
   * @memberof Map
   */
  closeCustomInfoWindow() {
    if (this._infoWindow) {
      this._infoWindow.close();
    }
  }

  /**
   * Returns if an info window is open and exists
   *
   * @public
   * @return {boolean} if window is open, true, else false
   * @memberof Map
   */
  isInfoWindowOpen() {
    if (this._infoWindow) {
      return true;
    }
    return false;
  }

  /**
   * Centers Map to lat, lng points using google.map.panTo
   * @public
   *
   * @param {string} lat
   * @param {string} lng
   * @memberof Map
   */
  panToLatLng(lat, lng) {
    let latLng = new this._google.maps.LatLng(lat, lng);

    this._map.panTo(latLng);
    this._map.setZoom(14);
  }

  /**
   * Centers Map to lat, lng points using google.map.panTo, fires a callback once the map tiles have loaded
   * @public
   *
   * @param {string} lat
   * @param {string} lng
   * @param {function} callback
   * @memberof Map
   */
  panToLatLngAsync(lat, lng, callback) {
    let latLng = new this._google.maps.LatLng(lat, lng);

    this._map.panTo(latLng);
    this._map.setZoom(14);
    this._google.maps.event.addListener(this._map, 'tilesloaded', () => {
      callback();
    });
  }

  /**
   * Sets up listeners for out of bounds when map is panned
   * @private
   *
   * @memberof Map
   */
  _setOutOfBoundsListener() {
    this._google.maps.event.addListener(this._map, 'dragend', () => {
      this._checkLatitude();
    });
    this._google.maps.event.addListener(this._map, 'idle', () => {
      this._checkLatitude();
    });
    this._google.maps.event.addListener(this._map, 'zoom_changed', () => {
      this._checkLatitude();
    });
  }

  /**
   * Checks latitude from the valid center set between -85 and 85 degrees latitude
   * @private
   *
   * @memberof Map
   */
  _checkLatitude() {
    if (this._minZoom) {
      if (this._map.getZoom() < this._minZoom) {
        this._map.setZoom(parseInt(this._minZoom, 10));
      }
    }

    let SW = this._map
      .getBounds()
      .getSouthWest()
      .lat();

    let NE = this._map
      .getBounds()
      .getNorthEast()
      .lat();

    if (SW < -85 || NE > 85) {
      if (this._lastValidCenter) {
        this._map.setCenter(this._lastValidCenter);
      }
    } else {
      this._lastValidCenter = this._map.getCenter();
    }
  }

  /**
   * Checks for location_id as a URL param and emits and event with the cleanup location marker
   *
   * @private
   * @memberof Map
   */
  _emitExistingLocation() {
    try {
      let params = new URLSearchParams(location.search.slice(1));

      let locationId = params.get('location_id');

      if (locationId) {
        let marker = this.cleanupLocationById(parseInt(locationId, 10));

        this._activeMarker = marker;
        this._emitEvent('map-url-location', this._activeMarker);
      }
    } catch (error) {
      console.error(error);
    }
  }

  /**
   * Checks for search as a query param in the URL and preforms a search if it exists
   *
   * @private
   * @param searchValue - text content that should be sent to the google places api via config.searchBoxElement
   * @return {string} search text from the URL or that provided to the method
   * @memberof Map
   */
  _emitExistingSearch(searchValue) {
    let searchParams;

    if (searchValue === undefined) {
      let params = new URLSearchParams(location.search.slice(1));

      searchParams = params.get('search');
    }

    if (searchParams || searchValue) {
      this._searchBoxElement.value = '';
      this._searchBoxElement.value = searchValue || searchParams;

      this._google.maps.event.trigger(this._searchBoxElement, 'focus', {});
      this._google.maps.event.trigger(this._searchBoxElement, 'keydown', { keyCode: 13 });
      return searchParams;
    }
    return searchParams;
  }

  /**
   * Returns a Google Maps Marker based on a wordpress data
   * @private
   *
   * @param {any} data
   * @returns {Marker} - Google Maps Marker
   * @memberof Map
   */
  _markerFactory(data) {
    let image = {
      url: this._markerIconPath,
      // This marker is 20 pixels wide by 32 pixels high.
      size: new this._google.maps.Size(32, 32),
      // The origin for this image is (0, 0).
      origin: new this._google.maps.Point(0, 0),
      // The anchor for this image is the base of the flagpole at (0, 32).
      anchor: new this._google.maps.Point(0, 32),
      // The scaling size
      scaledSize: new this._google.maps.Size(32, 32)
    };

    let baseMarkerOptions;

    try {
      let cleanupLocation = new CleanupLocation(data);

      let latLng = new this._google.maps.LatLng(cleanupLocation.latitude(), cleanupLocation.longitude());

      baseMarkerOptions = Object.assign(
        {
          position: latLng,
          icon: image,
          cleanupLocation
        },
        { info: data }
      );

      let marker = new this._google.maps.Marker(baseMarkerOptions);

      marker.addListener('mouseover', () => {
        this._emitEvent('marker-hover', marker);
      });

      marker.addListener('click', () => {
        // reset previous active marker if it exists
        this._resetMarkerIcon(this._activeMarker);

        // define new marker and set the clicked on path
        this._activeMarker = marker;

        if (this._onClickMarkerIconPath) {
          let clickedOnImage = {
            url: this._onClickMarkerIconPath,
            // This marker is 20 pixels wide by 32 pixels high.
            size: new this._google.maps.Size(32, 32),
            // The origin for this image is (0, 0).
            origin: new this._google.maps.Point(0, 0),
            // The anchor for this image is the base of the flagpole at (0, 32).
            anchor: new this._google.maps.Point(0, 32),
            // The scaling size
            scaledSize: new this._google.maps.Size(32, 32)
          };

          marker.setIcon(clickedOnImage);
        }
        this._emitEvent('marker-clicked', this._activeMarker);
      });

      return marker; // eslint-disable-line consistent-return
    } catch (error) {
      console.error(error);
      return; // eslint-disable-line consistent-return
    }
  }

  /**
   * Creates google.maps.InfoWindow instance
   * @private
   *
   * @param {Marker} marker - Google Maps Marker
   * @returns {InfoWindow} - Google Maps Info Window
   * @memberof Map
   */
  _infoWindowFactory(marker) {
    let contentString = '';

    if (this._infoWindowClickHandler) {
      contentString += `<div class="popover__container" onclick="${this._infoWindowClickHandler}(${marker.cleanupLocation.id()})">`;
    } else {
      contentString += `<div class="popover__container">`; // eslint-disable-line quotes
    }
    contentString += `
      <div class="popup">
        <div class="popup__image">
      `;

    if (marker.cleanupLocation.image() && marker.cleanupLocation.image() !== '') {
      contentString += `
       <img src=${marker.cleanupLocation.image()} alt="">
       `;
    } else {
      var imageUrl = `${this._markerPlaceholderImagesUrl}/photo${this.getPaddedRandomNumberBetween(1, 30)}.jpg`;

      contentString += `
       <img src=${imageUrl} alt="">
       `;
    }

    contentString += `
     </div>
        <div class="popup__content">
          <h3 class="popup__title">${marker.cleanupLocation.title()}</h3>
          <h4 class="popup__org">${marker.cleanupLocation._locationInformation.partnerName}</h4>
          <address class="popup__location">${marker.cleanupLocation.city()}, ${marker.cleanupLocation.state()}
            <img class="dot" src="/wp-content/themes/oco-wp/images/map/dot.png" width="5" height="5" alt="">${marker.cleanupLocation.country()}</address>
        </div>
      </div>
      </div>
    `;

    this._infoWindow = new this._google.maps.InfoWindow({
      content: contentString
    });

    return this._infoWindow;
  }

  /**
   * Creates google.maps.InfoBox instance
   * @private
   * @deprecated - use openCustomInfoWindow or openCustomInfoWindowAsync instead
   *
   * @param {string} htmlContent - HTML string to set to the content of the InfoWindow
   * @returns {<InfoWindow>} - Google Maps InfoWindow
   * @memberof Map
   */
  _customInfoWindowFactory(htmlContent) {
    this._infoWindow = new this._google.maps.InfoWindow({
      content: htmlContent
    });
    return this._infoWindow;
  }

  /**
   * Renders the marker cluster on the map
   * @private
   *
   * @returns {object} MarkerCluster instance
   * @memberof Map
   */
  _renderCluster(callback) {
    let markerCluster = new this._markerClusterer(this._map, this._visibleMarkers, {
      gridSize: 50,
      maxZoom: 7,
      imagePath: this._clusterMarkerIconPath,
      styles: this._customClusterStyles
    });

    if (callback) {
      this._emitEvent('map-cluster-rendered');
      callback(markerCluster);
    }
  }

  /**
   * Checks to see if window.google is available and waits to render the map when it is
   * @private
   *
   * @param {function} callback - callback to fire when window.google has loaded
   * @memberof Map
   */
  _isGoogleMapsLoaded(callback) {
    if (typeof window.google === 'object' && typeof window.google.maps === 'object') {
      callback();
    } else {
      setTimeout(function () {
        this._isGoogleMapsLoaded(callback);
      }, 250);
    }
  }

  /**
   * Render the map countdown header on the element given in the instance of the map, used for embedded freeSeas maps
   * @private
   *
   * @return {Promise<undefined, AxiosError>}
   * @memberof Map
   */
  _renderHeader() {
    return new Promise((resolve, reject) => {
      let displayData = Object.assign({ wordpressUrl: this._wordpressUrl });

      let div = headerTemplate(displayData);

      document.body.insertAdjacentHTML('beforeend', div);

      axios
        .get(`${this._wordpressUrl}/wp-json/wp/v2/acf/options`)
        .then(response => {
          displayData = Object.assign({ wordpressUrl: this._wordpressUrl }, response.data);

          let countDownDate = new Date(displayData.countdown_date).getTime();

          div = headerTemplate(displayData);

          document.getElementById('headerPanel').outerHTML = div;

          setInterval(() => {
            let now = new Date().getTime();

            let distance = countDownDate - now;

            let days = Math.floor(distance / (1000 * 60 * 60 * 24));

            let hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));

            if (hours.toString().length < 2) {
              hours = '0' + hours;
            }
            let minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));

            if (minutes.toString().length < 2) {
              minutes = '0' + minutes;
            }

            document.getElementById('days').innerHTML = '<span>' + days + '</span>';
            document.getElementById('hours').innerHTML = '<span>' + hours + '</span>';
            document.getElementById('minutes').innerHTML = '<span>' + minutes + '</span>';
            if (distance < 0) {
              document.getElementById('days').innerHTML = '<span>00</span>';
              document.getElementById('hours').innerHTML = '<span>00</span>';
              document.getElementById('minutes').innerHTML = '<span>00</span>';
            }
          }, 1000);

          resolve();
        })
        .catch(error => {
          reject(error);
        });
    });
  }

  /**
   * Render the map searchBar on the element given in the instance of the map, used for embedded freeSeas maps
   * @private
   *
   * @memberof Map
   */
  _renderSearch() {
    let div = searchTemplate({
      wordpressUrl: this._wordpressUrl
    });

    let body = document.getElementsByTagName('body')[0];

    body.insertAdjacentHTML('beforeend', div);

    this._searchBoxElement = document.getElementById('mapAutocomplete');
    this._searchBoxClickElement = document.getElementById('mapSearchSubmit');
  }

  _renderLoading() {
    let div = loadingTemplate();

    let body = document.getElementsByTagName('body')[0];

    body.insertAdjacentHTML('beforeend', div);
  }

  /**
   * Handles infoWindow popup logic for embedded freeSeas maps, opens a custom handlebars InfoBox with a link to
   * the ocean conservancy map
   * @private
   *
   * @memberof Map
   */
  _widgetPopupHandler(event) {
    let marker = event.detail;

    if (!marker.cleanupLocation.image() || marker.cleanupLocation.image() === '') {
      let randomNumber = this.getPaddedRandomNumberBetween(1, 30);

      let imageUrl = `${this._markerPlaceholderImagesUrl}/photo${randomNumber}.jpg`;

      marker.cleanupLocation._locationInformation.image = imageUrl;
      marker.cleanupLocation._locationInformation.isFillImage = true;
    }

    marker.cleanupLocation._locationInformation.dotImage = `${this._markerPlaceholderImagesUrl}/dot.png`;
    marker.cleanupLocation._locationInformation.locationLink = `${this._ocoMapUrl}?location_id=${marker.cleanupLocation.id()}`;

    let compiledPopupTemplate = popupTemplate(marker.cleanupLocation._locationInformation);

    // TODO: Figure out this weird side effect, have to call the method twice to actually open the info window on first click
    this.openCustomInfoWindow(marker, compiledPopupTemplate);
    this.openCustomInfoWindow(marker, compiledPopupTemplate);
  }

  _existingEmbedLocationHandler(event) {
    let marker = event.detail;

    if (!marker.cleanupLocation.image() || marker.cleanupLocation.image() === '') {
      let randomNumber = this.getPaddedRandomNumberBetween(1, 30);

      let imageUrl = `${this._markerPlaceholderImagesUrl}/photo${randomNumber}.jpg`;

      marker.cleanupLocation._locationInformation.image = imageUrl;
      marker.cleanupLocation._locationInformation.isFillImage = true;
    }

    marker.cleanupLocation._locationInformation.dotImage = `${this._markerPlaceholderImagesUrl}/dot.png`;
    marker.cleanupLocation._locationInformation.locationLink = `${this._ocoMapUrl}?location_id=${marker.cleanupLocation.id()}`;

    let compiledPopupTemplate = popupTemplate(marker.cleanupLocation._locationInformation);

    this.panToLatLng(marker.cleanupLocation.latitude(), marker.cleanupLocation.longitude());

    // TODO: Figure out this weird side effect, have to call the method twice to actually open the info window on first click
    this.openCustomInfoWindow(marker, compiledPopupTemplate);
    this.openCustomInfoWindow(marker, compiledPopupTemplate);
  }

  /**
   * Main rendering logic of the freeSeas map widget, here history is set for the map as well as various tasks to listen for google maps
   * DOM events and merge configuration logic with the initial setup of a freeSeas maps
   * @public
   *
   * @memberof Map
   */
  render() {
    InfoBox = require('google-maps-infobox');
    this._isGoogleMapsLoaded(() => {

      // Check if our options should change based on the map type and custom map options provided
      let mapOptions = this._shouldCenterOverNorthAmerica();

      this._customMapOptions = Object.assign(mapOptions, { gestureHandling: this._gestureHandling });

      if (this._element) {
        this._map = new this._google.maps.Map(this._element, this._customMapOptions);
      } else {
        this._renderLoading();
        this._renderHeader();
        this._renderSearch();

        this._element = document.createElement('div');
        this._element.setAttribute('id', 'map');

        let body = document.getElementsByTagName('body')[0];

        body.appendChild(this._element);

        this._map = new this._google.maps.Map(this._element, this._customMapOptions);

        // set events
        this._element.addEventListener('marker-clicked', this._widgetPopupHandler);
        this._element.addEventListener('map-cluster-rendered', () => {
          let element = document.getElementById('preloader');

          element.style.display = 'none';
        });
        this._element.addEventListener('map-location-history-pushed', this._widgetPopupHandler);
        this._element.addEventListener('map-location-history-popped', this._widgetPopupHandler);
        this._element.addEventListener('map-url-location', this._existingEmbedLocationHandler);

      }

      this._google.maps.event.addListener(this._map, 'click', event => {
        this._resetMarkerIcon(this._activeMarker);
      });

      // set search input and on click handler
      if (this._searchBoxElement) {
        this._setSearchBox(this._searchBoxElement);
        if (this._searchBoxClickElement) {
          this._setSearchBoxClickElement(this._searchBoxClickElement);
        }
      }

      // Set History
      const history = createBrowserHistory({
        basename: location.pathname, // The base URL of the app (see below)
        keyLength: 6 // Set true to force full page refreshes
      });

      window.mapHistory = history;

      window.mapHistory.listen((location, action) => {
        this._resetMarkerIcon(this._activeMarker);
        this.closeCustomInfoWindow();
        if (location.state) {
          if (location.state.type === 'location') {
            let marker;

            switch (action) {
              case 'PUSH':
                marker = this.cleanupLocationById(parseInt(location.state.locationId, 10));
                this._emitEvent('map-location-history-pushed', marker);
                break;
              case 'POP':
                marker = this.cleanupLocationById(parseInt(location.state.locationId, 10));
                this._emitEvent('map-location-history-popped', marker);
                break;
              default:
                this._resetMarkerIcon(this._activeMarker);
                break;
            }
          } else if (location.state.type === 'search') {
            this._emitExistingSearch(location.state.value);
            this._emitEvent('map-search-history-changed', location.state.value);
          }
        } else {
          try {
            let params = new URLSearchParams(location.search.slice(1));

            let locationId = parseInt(params.get('location_id'), 10);

            if (locationId) {
              let marker;

              switch (action) {
                case 'PUSH':
                  marker = this.cleanupLocationById(parseInt(locationId, 10));
                  this._emitEvent('map-location-history-pushed', marker);
                  break;
                case 'POP':
                  marker = this.cleanupLocationById(parseInt(locationId, 10));
                  this._emitEvent('map-location-history-popped', marker);
                  break;
                default:
                  this._resetMarkerIcon(this._activeMarker);
                  break;
              }
            } else {
              if (location.state) {
                this._emitEvent('map-search-history-changed', location.state.value);
              }
            }
          } catch (error) {
            console.error(error);
          }
        }
      });

      // Set zoom controls
      let zoomControlDiv = document.createElement('div');

      let zoomInButton = this._zoomInButton(zoomControlDiv, this._map); // eslint-disable-line no-unused-vars
      let zoomSlider = this._zoomSlider(this._map, this._minZoom, this._maxZoom, zoomControlDiv); // eslint-disable-line no-unused-vars
      let zoomOutButton = this._zoomOutButton(zoomControlDiv, this._map); // eslint-disable-line no-unused-vars

      zoomControlDiv.index = 1;

      this._map.controls[this._google.maps.ControlPosition.RIGHT_BOTTOM].push(zoomControlDiv);

      // Set Out Of Bounds Handler
      this._setOutOfBoundsListener();

      setTimeout(() => {
        this._setMarkers(this._map);
        this._emitEvent('map-loaded');
      }, 1000);

      // Wait for the map to idle before loading more points
      setTimeout(() => {
        this._google.maps.event.addListener(this._map, 'tilesloaded', () => {
          this._setAllVisibleMarkers(this._markers);

          // Render All Points
          this._renderCluster();

          // Idle the Map
          this._emitEvent('map-idled', this._visibleMarkers);
        });
      }, 2000);
    });
  }
}