<!-- Copyright (c) 2015 Google Inc. All rights reserved. -->

<link rel="import" href="../polymer/polymer.html">
<link rel="import" href="../google-apis/google-maps-api.html">
<link rel="import" href="../iron-resizable-behavior/iron-resizable-behavior.html">
<link rel="import" href="../iron-selector/iron-selector.html">
<link rel="import" href="google-map-marker.html">
<!--
The `google-map` element renders a Google Map.

<b>Example</b>:

    <style>
      google-map {
        height: 600px;
      }
    </style>
    <google-map latitude="37.77493" longitude="-122.41942"></google-map>

<b>Example</b> - add markers to the map and ensure they're in view:

    <google-map latitude="37.77493" longitude="-122.41942" fit-to-markers>
      <google-map-marker latitude="37.779" longitude="-122.3892"
          draggable="true" title="Go Giants!"></google-map-marker>
      <google-map-marker latitude="37.777" longitude="-122.38911"></google-map-marker>
    </google-map>

<b>Example</b>:

    <google-map disable-default-ui zoom="15"></google-map>
    <script>
      var map = document.querySelector('google-map');
      map.latitude = 37.77493;
      map.longitude = -122.41942;
      map.addEventListener('google-map-ready', function(e) {
        alert('Map loaded!');
      });
    </script>

<b>Example</b> - with Google directions, using data-binding inside another Polymer element

    <google-map map="{{map}}"></google-map>
    <google-map-directions map="{{map}}"
        start-address="San Francisco" end-address="Mountain View">
    </google-map-directions>

@demo
-->

<dom-module id="google-map">

  <style>
    :host {
      position: relative;
      display: block;
      height: 100%;
    }

    #map {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
    }

  </style>
  <template>
    <google-maps-api id="api"
      api-key="[[apiKey]]"
      client-id="[[clientId]]"
      version="[[version]]"
      signed-in="[[signedIn]]"
      language="[[language]]"
      on-api-load="_mapApiLoaded"></google-maps-api>

    <div id="map"></div>

    <iron-selector id="selector" multi="[[!singleInfoWindow]]" selected-attribute="open" activate-event="google-map-marker-open" on-google-map-marker-close="_deselectMarker">
      <content id="markers" select="google-map-marker"></content>
    </iron-selector>
    <content id="objects" select="*"></content>
  </template>
</dom-module>

<script>

  Polymer({

    is: 'google-map',


    /**
     * Fired when the Maps API has fully loaded.
     * @event google-map-ready
     */
    /**
     * Fired when the when the user clicks on the map (but not when they click on a marker, infowindow, or
     * other object). Requires the clickEvents attribute to be true.
     * @event google-map-click
     * @param {google.maps.MouseEvent} event The mouse event.
     */
    /**
     * Fired when the user double-clicks on the map. Note that the google-map-click event will also fire,
     * right before this one. Requires the clickEvents attribute to be true.
     * @event google-map-dblclick
     * @param {google.maps.MouseEvent} event The mouse event.
     */
    /**
     * Fired repeatedly while the user drags the map. Requires the dragEvents attribute to be true.
     * @event google-map-drag
     */
    /**
     * Fired when the user stops dragging the map. Requires the dragEvents attribute to be true.
     * @event google-map-dragend
     */
    /**
     * Fired when the user starts dragging the map. Requires the dragEvents attribute to be true.
     * @event google-map-dragstart
     */
    /**
     * Fired whenever the user's mouse moves over the map container. Requires the mouseEvents attribute to
     * be true.
     * @event google-map-mousemove
     * @param {google.maps.MouseEvent} event The mouse event.
     */
    /**
     * Fired when the user's mouse exits the map container. Requires the mouseEvents attribute to be true.
     * @event google-map-mouseout
     * @param {google.maps.MouseEvent} event The mouse event.
     */
    /**
     * Fired when the user's mouse enters the map container. Requires the mouseEvents attribute to be true.
     * @event google-map-mouseover
     * @param {google.maps.MouseEvent} event The mouse event.
     */
    /**
     * Fired when the DOM `contextmenu` event is fired on the map container. Requires the clickEvents
     * attribute to be true.
     * @event google-map-rightclick
     * @param {google.maps.MouseEvent} event The mouse event.
     */
    properties: {
      /**
       * A Maps API key. To obtain an API key, see developers.google.com/maps/documentation/javascript/tutorial#api_key.
       */
      apiKey: String,

      /**
       * A Maps API for Business Client ID. To obtain a Maps API for Business Client ID, see developers.google.com/maps/documentation/business/.
       * If set, a Client ID will take precedence over an API Key.
       */
      clientId: String,

      /**
       * A latitude to center the map on.
       */
      latitude: {
        type: Number,
        value: 37.77493,
        notify: true,
        reflectToAttribute: true
      },

      /**
       * A Maps API object.
       */
      map: {
        type: Object,
        notify: true,
        value: null
      },

      /**
       * A longitude to center the map on.
       */
      longitude: {
        type: Number,
        value: -122.41942,
        notify: true,
        reflectToAttribute: true
      },

      /**
       * A kml file to load.
       */
      kml: {
        type: String,
        value: null,
        observer: '_loadKml'
      },

      /**
       * A zoom level to set the map to.
       */
      zoom: {
        type: Number,
        value: 10,
        observer: '_zoomChanged',
        notify: true
      },

      /**
       * When set, prevents the map from tilting (when the zoom level and viewport supports it).
       */
      noAutoTilt: {
        type: Boolean,
        value: false
      },

      /**
       * Map type to display. One of 'roadmap', 'satellite', 'hybrid', 'terrain'.
       */
      mapType: {
        type: String,
        value: 'roadmap', // roadmap, satellite, hybrid, terrain,
        observer: '_mapTypeChanged',
        notify: true
      },

      /**
       * Version of the Google Maps API to use.
       */
      version: {
        type: String,
        value: '3.exp'
      },

      /**
       * If set, removes the map's default UI controls.
       */
      disableDefaultUi: {
        type: Boolean,
        value: false,
        observer: '_disableDefaultUiChanged'
      },

      /**
       * If set, the zoom level is set such that all markers (google-map-marker children) are brought into view.
       */
      fitToMarkers: {
        type: Boolean,
        value: false,
        observer: '_fitToMarkersChanged'
      },

      /**
       * If true, prevent the user from zooming the map interactively.
       */
      disableZoom: {
        type: Boolean,
        value: false,
        observer: '_disableZoomChanged'
      },

      /**
       * If set, custom styles can be applied to the map.
       * For style documentation see developers.google.com/maps/documentation/javascript/reference#MapTypeStyle
       */
      styles: {
        type: Object,
        value: function() { return {}; }
      },

      /**
       * A maximum zoom level which will be displayed on the map.
       */
      maxZoom: {
        type: Number,
        observer: '_maxZoomChanged'
      },

      /**
       * A minimum zoom level which will be displayed on the map.
       */
      minZoom: {
        type: Number,
        observer: '_minZoomChanged'
      },

      /**
       * If true, sign-in is enabled.
       * See https://developers.google.com/maps/documentation/javascript/signedin#enable_sign_in
       */
      signedIn: {
        type: Boolean,
        value: false
      },

      /**
       * The localized language to load the Maps API with. For more information
       * see https://developers.google.com/maps/documentation/javascript/basics#Language
       *
       * Note: the Maps API defaults to the preffered language setting of the browser.
       * Use this parameter to override that behavior.
       */
      language: {
        type: String
      },

      /**
       * When true, map *click events are automatically registered.
       */
      clickEvents: {
        type: Boolean,
        value: false,
        observer: '_clickEventsChanged'
      },

      /**
       * When true, map drag* events are automatically registered.
       */
      dragEvents: {
        type: Boolean,
        value: false,
        observer: '_dragEventsChanged'
      },

      /**
       * When true, map mouse* events are automatically registered.
       */
      mouseEvents: {
        type: Boolean,
        value: false,
        observer: '_mouseEventsChanged'
      },

      /**
       * Additional map options for google.maps.Map constructor.
       * Use to specify additional options we do not expose as
       * properties.
       * Ex: `<google-map additional-map-options='{"mapTypeId":"satellite"}'>`
       *
       * Note, you can't use API enums like `google.maps.ControlPosition.TOP_RIGHT`
       * when using this property as an HTML attribute. Instead, use the actual
       * value (e.g. `3`) or set `.additionalMapOptions` in JS rather than using
       * the attribute.
       */
      additionalMapOptions: {
        type: Object,
        value: function() { return {}; }
      },

      /**
       * The markers on the map.
       */
      markers: {
        type: Array,
        value: function() { return []; },
        readOnly: true
      },

      /**
       * The non-marker objects on the map.
       */
      objects: {
        type: Array,
        value: function() { return []; },
        readOnly: true
      },

      /**
       * If set, all other info windows on markers are closed when opening a new one.
       */
      singleInfoWindow: {
        type: Boolean,
        value: false
      }
    },

    behaviors: [
      Polymer.IronResizableBehavior
    ],

    listeners: {
      'iron-resize': 'resize'
    },

    observers: [
      '_debounceUpdateCenter(latitude, longitude)'
    ],

    attached: function() {
      this._initGMap();
    },

    detached: function() {
      if (this._mutationObserver) {
        this._mutationObserver.disconnect();
        this._mutationObserver = null;
      }
      if (this._objectsMutationObserver) {
        this._objectsMutationObserver.disconnect();
        this._objectsMutationObserver = null;
      }
    },

    _initGMap: function() {
      if (this.map) {
        return; // already initialized
      }
      if (this.$.api.libraryLoaded !== true) {
        return; // api not loaded
      }
      if (!this.isAttached) {
        return; // not attached
      }

      this.map = new google.maps.Map(this.$.map, this._getMapOptions());
      this._listeners = {};
      this._updateCenter();
      this._loadKml();
      this._updateMarkers();
      this._updateObjects();
      this._addMapListeners();
      this.fire('google-map-ready');
    },

    _mapApiLoaded: function() {
      this._initGMap();
    },

    _getMapOptions: function() {
      var mapOptions = {
        zoom: this.zoom,
        tilt: this.noAutoTilt ? 0 : 45,
        mapTypeId: this.mapType,
        disableDefaultUI: this.disableDefaultUi,
        disableDoubleClickZoom: this.disableZoom,
        scrollwheel: !this.disableZoom,
        styles: this.styles,
        maxZoom: Number(this.maxZoom),
        minZoom: Number(this.minZoom)
      };

      // Only override the default if set.
      // We use getAttribute here because the default value of this.draggable = false even when not set.
      if (this.getAttribute('draggable') != null) {
        mapOptions.draggable = this.draggable
      }
      for (var p in this.additionalMapOptions)
        mapOptions[p] = this.additionalMapOptions[p];

      return mapOptions;
    },

    _attachChildrenToMap: function(children) {
      if (this.map) {
        for (var i = 0, child; child = children[i]; ++i) {
          child.map = this.map;
        }
      }
    },

    // watch for future updates to marker objects
    _observeMarkers: function() {
      // Watch for future updates.
      if (this._mutationObserver) {
        return;
      }
      this._mutationObserver = new MutationObserver(this._updateMarkers.bind(this));
      this._mutationObserver.observe(this.$.selector, {
        childList: true
      });
    },

    _updateMarkers: function() {
      var newMarkers = Array.prototype.slice.call(
          Polymer.dom(this.$.markers).getDistributedNodes());

      // do not recompute if markers have not been added or removed
      if (newMarkers.length === this.markers.length) {
        var added = newMarkers.filter(function(m) {
          return this.markers && this.markers.indexOf(m) === -1;
        }.bind(this));
        if (added.length === 0) {
          // set up observer first time around
          if (!this._mutationObserver) {
            this._observeMarkers();
          }
          return;
        }
      }

      this._observeMarkers();

      this.markers = this._setMarkers(newMarkers);

      // Set the map on each marker and zoom viewport to ensure they're in view.
      this._attachChildrenToMap(this.markers);
      if (this.fitToMarkers) {
        this._fitToMarkersChanged();
      }
    },

    // watch for future updates to non-marker objects
    _observeObjects: function() {
      if (this._objectsMutationObserver) {
        return;
      }
      this._objectsMutationObserver = new MutationObserver(this._updateObjects.bind(this));
      this._objectsMutationObserver.observe(this, {
        childList: true
      });
    },

    _updateObjects: function() {
      var newObjects = Array.prototype.slice.call(
          Polymer.dom(this.$.objects).getDistributedNodes());

      // Do not recompute if objects have not been added or removed.
      if (newObjects.length === this.objects.length) {
        var added = newObjects.filter(function(o) {
          return this.objects.indexOf(o) === -1;
        }.bind(this));
        if (added.length === 0) {
          // Set up observer first time around.
          this._observeObjects();
          return;
        }
      }

      this._observeObjects();
      this._setObjects(newObjects);
      this._attachChildrenToMap(this.objects);
    },

    /**
     * Clears all markers from the map.
     *
     * @method clear
     */
    clear: function() {
      for (var i = 0, m; m = this.markers[i]; ++i) {
        m.marker.setMap(null);
      }
    },

    /**
     * Explicitly resizes the map, updating its center. This is useful if the
     * map does not show after you have unhidden it.
     *
     * @method resize
     */
    resize: function() {
      if (this.map) {

        // saves and restores latitude/longitude because resize can move the center
        var oldLatitude = this.latitude;
        var oldLongitude = this.longitude;
        google.maps.event.trigger(this.map, 'resize');
        this.latitude = oldLatitude;  // restore because resize can move our center
        this.longitude = oldLongitude;

        if (this.fitToMarkers) { // we might not have a center if we are doing fit-to-markers
          this._fitToMarkersChanged();
        }
      }
    },

    _loadKml: function() {
      if (this.kml) {
        var kmlfile = new google.maps.KmlLayer({
          url: this.kml,
          map: this.map
        });
      }
    },

    _debounceUpdateCenter: function() {
      this.debounce('updateCenter', this._updateCenter);
    },

    _updateCenter: function() {
      this.cancelDebouncer('updateCenter');

      if (this.map && this.latitude !== undefined && this.longitude !== undefined) {
        // allow for latitude and longitude to be String-typed, but still Number valued
        var lati = Number(this.latitude);
        if (isNaN(lati)) {
          throw new TypeError('latitude must be a number');
        }
        var longi = Number(this.longitude);
        if (isNaN(longi)) {
          throw new TypeError('longitude must be a number');
        }

        var newCenter = new google.maps.LatLng(lati, longi);
        var oldCenter = this.map.getCenter();

        if (!oldCenter) {
          // If the map does not have a center, set it right away.
          this.map.setCenter(newCenter);
        } else {
          // Using google.maps.LatLng returns corrected lat/lngs.
          oldCenter = new google.maps.LatLng(oldCenter.lat(), oldCenter.lng());

          // If the map currently has a center, slowly pan to the new one.
          if (!oldCenter.equals(newCenter)) {
            this.map.panTo(newCenter);
          }
        }
      }
    },

    _zoomChanged: function() {
      if (this.map) {
        this.map.setZoom(Number(this.zoom));
      }
    },

    _clickEventsChanged: function() {
      if (this.map) {
        if (this.clickEvents) {
          this._forwardEvent('click');
          this._forwardEvent('dblclick');
          this._forwardEvent('rightclick');
        } else {
          this._clearListener('click');
          this._clearListener('dblclick');
          this._clearListener('rightclick');
        }
      }
    },

    _dragEventsChanged: function() {
      if (this.map) {
        if (this.dragEvents) {
          this._forwardEvent('drag');
          this._forwardEvent('dragend');
          this._forwardEvent('dragstart');
        } else {
          this._clearListener('drag');
          this._clearListener('dragend');
          this._clearListener('dragstart');
        }
      }
    },

    _mouseEventsChanged: function() {
      if (this.map) {
        if (this.mouseEvents) {
          this._forwardEvent('mousemove');
          this._forwardEvent('mouseout');
          this._forwardEvent('mouseover');
        } else {
          this._clearListener('mousemove');
          this._clearListener('mouseout');
          this._clearListener('mouseover');
        }
      }
    },

    _maxZoomChanged: function() {
      if (this.map) {
        this.map.setOptions({maxZoom: Number(this.maxZoom)});
      }
    },

    _minZoomChanged: function() {
      if (this.map) {
        this.map.setOptions({minZoom: Number(this.minZoom)});
      }
    },

    _mapTypeChanged: function() {
      if (this.map) {
        this.map.setMapTypeId(this.mapType);
      }
    },

    _disableDefaultUiChanged: function() {
      if (!this.map) {
        return;
      }
      this.map.setOptions({disableDefaultUI: this.disableDefaultUi});
    },

    _disableZoomChanged: function() {
      if (!this.map) {
        return;
      }
      this.map.setOptions({
        disableDoubleClickZoom: this.disableZoom,
        scrollwheel: !this.disableZoom
      });
    },

    attributeChanged: function(attrName, oldVal, newVal) {
      if (!this.map) {
        return;
      }
      // Cannot use *Changed watchers for native properties.
      switch (attrName) {
        case 'draggable':
          this.map.setOptions({draggable: this.draggable});
          break;
      }
    },

    _fitToMarkersChanged: function() {
      // TODO(ericbidelman): respect user's zoom level.

      if (this.map && this.fitToMarkers && this.markers.length > 0) {
        var latLngBounds = new google.maps.LatLngBounds();
        for (var i = 0, m; m = this.markers[i]; ++i) {
          latLngBounds.extend(
              new google.maps.LatLng(m.latitude, m.longitude));
        }

        // For one marker, don't alter zoom, just center it.
        if (this.markers.length > 1) {
          this.map.fitBounds(latLngBounds);
        }

        this.map.setCenter(latLngBounds.getCenter());
      }
    },

    _addMapListeners: function() {
      google.maps.event.addListener(this.map, 'center_changed', function() {
        var center = this.map.getCenter();
        this.latitude = center.lat();
        this.longitude = center.lng();
      }.bind(this));

      google.maps.event.addListener(this.map, 'zoom_changed', function() {
        this.zoom = this.map.getZoom();
      }.bind(this));

      google.maps.event.addListener(this.map, 'maptypeid_changed', function() {
        this.mapType = this.map.getMapTypeId();
      }.bind(this));

      this._clickEventsChanged();
      this._dragEventsChanged();
      this._mouseEventsChanged();
    },

    _clearListener: function(name) {
      if (this._listeners[name]) {
        google.maps.event.removeListener(this._listeners[name]);
        this._listeners[name] = null;
      }
    },

    _forwardEvent: function(name) {
      this._listeners[name] = google.maps.event.addListener(this.map, name, function(event) {
        this.fire('google-map-' + name, event);
      }.bind(this));
    },

   _deselectMarker: function(e, detail) {
     // If singleInfoWindow is set, update iron-selector's selected attribute to be null.
     // Else remove the marker from iron-selector's selected array.
     var markerIndex = this.$.selector.indexOf(e.target);

     if (this.singleInfoWindow) {
      this.$.selector.selected = null;
     } else if (this.$.selector.selectedValues) {
      this.$.selector.selectedValues = this.$.selector.selectedValues.filter(function(i) {return i !== markerIndex});
     }
   }

  });

</script>