Source: src/component/Map.js

/* Copyright (c) 2015-present The Open Source Geospatial Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
/**
 * A component that renders an `ol.Map` and that can be used in any ExtJS
 * layout.
 *
 * An example: A map component rendered inside of a panel:
 *
 *     @example preview
 *     var mapComponent = Ext.create('GeoExt.component.Map', {
 *         map: new ol.Map({
 *             layers: [
 *                 new ol.layer.Tile({
 *                     source: new ol.source.OSM()
 *                 })
 *             ],
 *             view: new ol.View({
 *                 center: ol.proj.fromLonLat([-8.751278, 40.611368]),
 *                 zoom: 12
 *             })
 *         })
 *     });
 *     var mapPanel = Ext.create('Ext.panel.Panel', {
 *         title: 'GeoExt.component.Map Example',
 *         height: 200,
 *         items: [mapComponent],
 *         renderTo: Ext.getBody()
 *     });
 *
 * @class GeoExt.component.Map
 */
Ext.define('GeoExt.component.Map', {
  extend: 'Ext.Component',
  alias: ['widget.gx_map', 'widget.gx_component_map'],
  requires: ['GeoExt.data.store.Layers', 'GeoExt.util.Version'],
  mixins: ['GeoExt.mixin.SymbolCheck'],

  // <debug>
  symbols: [
    'ol.layer.Base',
    'ol.Map',
    'ol.Map#addLayer',
    'ol.Map#getLayers',
    'ol.Map#getSize',
    'ol.Map#getView',
    'ol.Map#removeLayer',
    'ol.Map#setTarget',
    'ol.Map#setView',
    'ol.Map#updateSize',
    'ol.View',
    'ol.View#calculateExtent',
    'ol.View#fit',
    'ol.View#getCenter',
    'ol.View#setCenter',
  ],
  // </debug>

  /**
   * @event pointerrest
   *
   * Fires if the user has left the pointer for an amount
   * of #pointerRestInterval milliseconds at the *same location*. Use the
   * configuration #pointerRestPixelTolerance to configure how long a pixel is
   * considered to be on the *same location*.
   *
   * Please note that this event will only fire if the map has #pointerRest
   * configured with `true`.
   *
   * @param {ol.MapBrowserEvent} olEvt The original and most recent
   *     MapBrowserEvent event.
   * @param {ol.Pixel} lastPixel The originally captured pixel, which defined
   *     the center of the tolerance bounds (itself configurable with the the
   *     configuration #pointerRestPixelTolerance). If this is null, a
   *     completely *new* pointerrest event just happened.
   */

  /**
   * @event pointerrestout
   *
   * Fires if the user first was resting his pointer on the map element, but
   * then moved the pointer out of the map completely.
   *
   * Please note that this event will only fire if the map has #pointerRest
   * configured with `true`.
   *
   * @param {ol.MapBrowserEvent} olEvt The MapBrowserEvent event.
   */

  /**
   * @event aftermapmove
   *
   * Triggered when the 'moveend' event of the underlying OpenLayers map is
   * fired.
   *
   * @param {GeoExt.component.Map} this
   * @param {ol.Map} olMap The OpenLayers map firing the original 'moveend'
   *     event
   * @param {ol.MapEvent} olEvt The original OpenLayers event
   */

  stateEvents: ['aftermapmove'],

  config: {
    /**
     * A configured map or a configuration object for the map constructor.
     *
     * @cfg {ol.Map} map
     */
    map: null,

    /**
     * A boolean flag to control whether the map component will fire the
     * events #pointerrest and #pointerrestout. If this is set to `false`
     * (the default), no such events will be fired.
     *
     * @cfg {boolean} pointerRest Whether the component shall provide the
     *     `pointerrest` and `pointerrestout` events.
     */
    pointerRest: false,

    /**
     * The amount of milliseconds after which we will consider a rested
     * pointer as `pointerrest`. Only relevant if #pointerRest is `true`.
     *
     * @cfg {number} pointerRestInterval The interval in milliseconds.
     */
    pointerRestInterval: 1000,

    /**
     * The amount of pixels that a pointer may move in both vertical and
     * horizontal direction, and still be considered to be a #pointerrest.
     * Only relevant if #pointerRest is `true`.
     *
     * @cfg {number} pointerRestPixelTolerance The tolerance in pixels.
     */
    pointerRestPixelTolerance: 3,

    /**
     * List of css selectors for the element(s) on which neither
     * the pointerrest event, nor the pointerrestout event
     * should be fired.
     *
     * @cfg {Array<string>} ignorePointerRestSelectors The css selectors
     *      on which no `pointerrest` and `pointerrestout` events
     *      should be fired.
     */
    ignorePointerRestSelectors: [],
  },

  /**
   * Whether we already rendered an ol.Map in this component. Will be
   * updated in #onResize, after the first rendering happened.
   *
   * @property {boolean} mapRendered Map already rendered flag
   * @private
   */
  mapRendered: false,

  /**
   * @property {GeoExt.data.store.Layers} layerStore The map's layer store
   * @private
   */
  layerStore: null,

  /**
   * The location of the last mousemove which we track to be able to fire
   * the #pointerrest event. Only usable if #pointerRest is `true`.
   *
   * @property {ol.Pixel} lastPointerPixel Location of last mousemove
   * @private
   */
  lastPointerPixel: null,

  /**
   * Whether the pointer is currently over the map component. Only usable if
   * the configuration #pointerRest is `true`.
   *
   * @property {boolean} isMouseOverMapEl Pointer is over map component flag
   * @private
   */
  isMouseOverMapEl: null,

  /**
   * @inheritdoc
   */
  constructor: function (config) {
    const me = this;

    me.callParent([config]);

    if (!(me.getMap() instanceof ol.Map)) {
      const olMap = new ol.Map({
        view: new ol.View({
          center: [0, 0],
          zoom: 2,
        }),
      });
      me.setMap(olMap);
    }

    me.layerStore = Ext.create('GeoExt.data.store.Layers', {
      storeId: me.getId() + '-store',
      map: me.getMap(),
    });

    me.bindStateOlEvents();

    me.on('resize', me.onResize, me);
  },

  /**
   * (Re-)render the map when size changes.
   */
  onResize: function () {
    // Get the corresponding view of the controller (the mapComponent).
    const me = this;
    if (!me.mapRendered) {
      const el = me.getTargetEl ? me.getTargetEl() : me.element;
      me.getMap().setTarget(el.dom);
      me.mapRendered = true;
    } else {
      me.getMap().updateSize();
    }
  },

  /**
   * Will contain a buffered version of #unbufferedPointerMove, but only if
   * the configuration #pointerRest is true.
   *
   * @private
   */
  bufferedPointerMove: Ext.emptyFn,

  /**
   * Bound as a eventlistener for pointermove on the OpenLayers map, but only
   * if the configuration #pointerRest is true. Will eventually fire the
   * special events #pointerrest or #pointerrestout.
   *
   * @param {ol.MapBrowserEvent} olEvt The MapBrowserEvent event.
   * @private
   */
  unbufferedPointerMove: function (olEvt) {
    const me = this;
    const tolerance = me.getPointerRestPixelTolerance();
    const pixel = olEvt.pixel;

    if (me.isMouseOverIgnoreEl(olEvt)) {
      return;
    }

    if (!me.isMouseOverMapEl) {
      me.fireEvent('pointerrestout', olEvt);
      return;
    }

    if (me.lastPointerPixel) {
      const deltaX = Math.abs(me.lastPointerPixel[0] - pixel[0]);
      const deltaY = Math.abs(me.lastPointerPixel[1] - pixel[1]);
      if (deltaX > tolerance || deltaY > tolerance) {
        me.lastPointerPixel = pixel;
      } else {
        // fire pointerrest, and include the original pointer pixel
        me.fireEvent('pointerrest', olEvt, me.lastPointerPixel);
        return;
      }
    } else {
      me.lastPointerPixel = pixel;
    }
    // a new pointerrest event, the second argument (the 'original' pointer
    // pixel) must be null, as we start from a totally new position
    me.fireEvent('pointerrest', olEvt, null);
  },

  /**
   * Checks if the mouse is positioned over
   * an ignore element.
   * @return {boolean} Whether the mouse is positioned over an ignore element.
   */
  isMouseOverIgnoreEl: function () {
    const me = this;
    const selectors = me.getIgnorePointerRestSelectors();
    if (selectors === undefined || selectors.length === 0) {
      return false;
    }

    const hoverEls = Ext.query(':hover');
    return hoverEls.some(function (el) {
      return selectors.some(function (sel) {
        return el.matches(sel);
      });
    });
  },

  /**
   * Creates #bufferedPointerMove from #unbufferedPointerMove and binds it
   * to `pointermove` on the OpenLayers map.
   *
   * @private
   */
  registerPointerRestEvents: function () {
    const me = this;
    const map = me.getMap();

    if (me.bufferedPointerMove === Ext.emptyFn) {
      me.bufferedPointerMove = Ext.Function.createBuffered(
        me.unbufferedPointerMove,
        me.getPointerRestInterval(),
        me,
      );
    }

    // Check if we have to fire any pointer* events
    map.on('pointermove', me.bufferedPointerMove);

    if (!me.rendered) {
      // make sure we do not fire any if the pointer left the component
      me.on('afterrender', me.bindOverOutListeners, me);
    } else {
      me.bindOverOutListeners();
    }
  },

  /**
   * Registers listeners that'll take care of setting #isMouseOverMapEl to
   * correct values.
   *
   * @private
   */
  bindOverOutListeners: function () {
    const me = this;
    const mapEl = me.getTargetEl ? me.getTargetEl() : me.element;
    if (mapEl) {
      mapEl.on({
        mouseover: me.onMouseOver,
        mouseout: me.onMouseOut,
        scope: me,
      });
    }
  },

  /**
   * Unregisters listeners that'll take care of setting #isMouseOverMapEl to
   * correct values.
   *
   * @private
   */
  unbindOverOutListeners: function () {
    const me = this;
    const mapEl = me.getTargetEl ? me.getTargetEl() : me.element;
    if (mapEl) {
      mapEl.un({
        mouseover: me.onMouseOver,
        mouseout: me.onMouseOut,
        scope: me,
      });
    }
  },

  /**
   * Sets isMouseOverMapEl to true, see #pointerRest.
   *
   * @private
   */
  onMouseOver: function () {
    this.isMouseOverMapEl = true;
  },

  /**
   * Sets isMouseOverMapEl to false, see #pointerRest.
   *
   * @private
   */
  onMouseOut: function () {
    this.isMouseOverMapEl = false;
  },

  /**
   * Unregisters the #bufferedPointerMove event listener and unbinds the
   * over- and out-listeners.
   */
  unregisterPointerRestEvents: function () {
    const me = this;
    const map = me.getMap();
    me.unbindOverOutListeners();
    if (map) {
      map.un('pointermove', me.bufferedPointerMove);
    }
    me.bufferedPointerMove = Ext.emptyFn;
  },

  /**
   * Whenever the value of #pointerRest is changed, this method will take
   * care of registering or unregistering internal event listeners.
   *
   * @param {boolean} val The new value that someone set for `pointerRest`.
   * @return {boolean} The passed new value for `pointerRest` unchanged.
   */
  applyPointerRest: function (val) {
    if (val) {
      this.registerPointerRestEvents();
    } else {
      this.unregisterPointerRestEvents();
    }
    return val;
  },

  /**
   * Whenever the value of #pointerRestInterval is changed, this method will
   * take to reinitialize the #bufferedPointerMove method and handlers to
   * actually trigger the event.
   *
   * @param {boolean} val The new value that someone set for
   *     `pointerRestInterval`.
   * @return {boolean} The passed new value for `pointerRestInterval`
   *     unchanged.
   */
  applyPointerRestInterval: function (val) {
    const me = this;
    const isEnabled = me.getPointerRest();
    if (isEnabled) {
      // Toggle to rebuild the buffered pointer move.
      me.setPointerRest(false);
      me.setPointerRest(isEnabled);
    }
    return val;
  },

  /**
   * Returns the center coordinate of the view.
   *
   * @return {ol.Coordinate} The center of the map view as `ol.Coordinate`.
   */
  getCenter: function () {
    return this.getMap().getView().getCenter();
  },

  /**
   * Set the center of the view.
   *
   * @param {ol.Coordinate} center The new center as `ol.Coordinate`.
   */
  setCenter: function (center) {
    this.getMap().getView().setCenter(center);
  },

  /**
   * Returns the extent of the current view.
   *
   * @return {ol.Extent} The extent of the map view as `ol.Extent`.
   */
  getExtent: function () {
    return this.getView().calculateExtent(this.getMap().getSize());
  },

  /**
   * Set the extent of the view.
   *
   * @param {ol.Extent} extent The extent as `ol.Extent`.
   */
  setExtent: function (extent) {
    // Check for backwards compatibility
    if (GeoExt.util.Version.isOl3()) {
      this.getView().fit(extent, this.getMap().getSize());
    } else {
      this.getView().fit(extent);
    }
  },

  /**
   * Returns the layers of the map.
   *
   * @return {ol.Collection} The layer collection.
   */
  getLayers: function () {
    return this.getMap().getLayers();
  },

  /**
   * Add a layer to the map.
   *
   * @param {ol.layer.Base} layer The layer to add.
   */
  addLayer: function (layer) {
    if (layer instanceof ol.layer.Base) {
      this.getMap().addLayer(layer);
    } else {
      Ext.Error.raise(
        'Can not add layer ' +
          layer +
          ' as it is not ' +
          'an instance of ol.layer.Base',
      );
    }
  },

  /**
   * Remove a layer from the map.
   *
   * @param {ol.layer.Base} layer The layer to remove.
   */
  removeLayer: function (layer) {
    if (layer instanceof ol.layer.Base) {
      if (Ext.Array.contains(this.getLayers().getArray(), layer)) {
        this.getMap().removeLayer(layer);
      }
    } else {
      Ext.Error.raise(
        'Can not remove layer ' +
          layer +
          ' as it is not ' +
          'an instance of ol.layer.Base',
      );
    }
  },

  /**
   * Returns the `GeoExt.data.store.Layers`.
   *
   * @return {GeoExt.data.store.Layers} The layer store.
   */
  getStore: function () {
    return this.layerStore;
  },

  /**
   * Returns the view of the map.
   *
   * @return {ol.View} The `ol.View` of the map.
   */
  getView: function () {
    return this.getMap().getView();
  },

  /**
   * Set the view of the map.
   *
   * @param {ol.View} view The `ol.View` to use for the map.
   */
  setView: function (view) {
    this.getMap().setView(view);
  },

  /**
   * Forwards the OpenLayers events so they become usable in the #statedEvents
   * array and a possible `GeoExt.state.PermalinkProvider` can change the
   * state when one of the events gets fired.
   */
  bindStateOlEvents: function () {
    const me = this;
    const olMap = me.getMap();
    olMap.on('moveend', function (evt) {
      me.fireEvent('aftermapmove', me, olMap, evt);
    });
  },

  /**
   * Returns the state of the map as keyed object. The following keys will be
   * available:
   *
   * * `center`
   * * `zoom`
   * * `rotation`
   *
   * @return {Object} The state object
   * @private
   */
  getState: function () {
    const me = this;
    const view = me.getMap().getView();
    return {
      zoom: view.getZoom(),
      center: view.getCenter(),
      rotation: view.getRotation(),
    };
  },

  /**
   * Apply the provided map state object. The following keys are interpreted:
   *
   * * `center`
   * * `zoom`
   * * `rotation`
   *
   * @param  {Object} mapState The state object
   */
  applyState: function (mapState) {
    // exit if no map state is provided
    if (!Ext.isObject(mapState)) {
      return;
    }

    const me = this;
    const view = me.getMap().getView();

    view.setCenter(mapState.center);
    view.setZoom(mapState.zoom);
    view.setRotation(mapState.rotation);
  },
});