Source: classic/selection/FeatureModelMixin.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 mixin for selection model which enables automatic selection of features
 * in the map when rows are selected in the grid and vice-versa.
 *
 * **CAUTION: This class is only usable in applications using the classic
 * toolkit of ExtJS 6.**
 *
 * @class GeoExt.selection.FeatureModelMixin
 */

Ext.define('GeoExt.selection.FeatureModelMixin', {
  extend: 'Ext.Mixin',

  mixinConfig: {
    after: {
      bindComponent: 'bindFeatureModel',
    },
    before: {
      destroy: 'unbindOlEvents',
      constructor: 'onConstruct',
      onSelectChange: 'beforeSelectChange',
    },
  },

  config: {
    /**
     * The connected vector layer.
     * @cfg {ol.layer.Vector} layer The connected vector layer.
     * @property {ol.layer.Vector} layer The connected vector layer.
     */
    layer: null,

    /**
     * The OpenLayers map we work with
     * @cfg {ol.Map}
     * @type {ol.Map}
     */
    map: null,

    /**
     * Set to true to create a click handler on the map selecting a clicked
     * object in the #layer.
     * @cfg {boolean}
     */
    mapSelection: false,

    /**
     * Set a pixel tolerance for the map selection. Defaults to 12.
     */
    selectionTolerance: 12,

    /**
     * The default style for the selected features.
     * @cfg {ol.style.Style}
     */
    selectStyle: new ol.style.Style({
      image: new ol.style.Circle({
        radius: 6,
        fill: new ol.style.Fill({
          color: 'rgba(255,255,255,0.8)',
        }),
        stroke: new ol.style.Stroke({
          color: 'darkblue',
          width: 2,
        }),
      }),
      fill: new ol.style.Fill({
        color: 'rgba(255,255,255,0.8)',
      }),
      stroke: new ol.style.Stroke({
        color: 'darkblue',
        width: 2,
      }),
    }),
  },

  /**
   * Lookup to preserve existing feature styles. Used to restore feature style
   * when select style is removed.
   * @private
   * @property {Object} existingFeatStyles Lookup to preserve existing feature styles
   */
  existingFeatStyles: {},

  /**
   * Indicates if a map click handler has been registered on init.
   * @private
   * @property {boolean} mapClickRegistered Indicates if a map click handler has been registered
   */
  mapClickRegistered: false,

  /**
   * The attribute key to mark an OL feature as selected.
   * @cfg {string}
   * @property  {string} selectedFeatureAttr The attribute key to mark as selected
   * @readonly
   */
  selectedFeatureAttr: 'gx_selected',

  /**
   * The currently selected features (`ol.Collection` containing `ol.Feature`
   * instances).
   * @property {ol.Collection} selectedFeatures Currently selected features
   */
  selectedFeatures: null,

  onConstruct: function () {
    const me = this;

    me.onSelectFeatAdd = me.onSelectFeatAdd.bind(me);
    me.onSelectFeatRemove = me.onSelectFeatRemove.bind(me);
    me.onFeatureClick = me.onFeatureClick.bind(me);
  },

  /**
   * Prepare several connected objects once the selection model is ready.
   *
   * @private
   */
  bindFeatureModel: function () {
    const me = this;

    // detect a layer from the store if not passed in
    if (!me.layer || !(me.layer instanceof ol.layer.Vector)) {
      const store = me.getStore();
      if (
        store &&
        store.getLayer &&
        store.getLayer() &&
        store.getLayer() instanceof ol.layer.Vector
      ) {
        me.layer = store.getLayer();
      }
    }

    // bind several OL events since this is not called while destroying
    me.bindOlEvents();
  },

  /**
   * Binds several events on the OL objects used in this class.
   *
   * @private
   */
  bindOlEvents: function () {
    if (!this.bound_) {
      const me = this;

      me.selectedFeatures = new ol.Collection();

      // change style of selected feature
      me.selectedFeatures.on('add', me.onSelectFeatAdd);

      // reset style of no more selected feature
      me.selectedFeatures.on('remove', me.onSelectFeatRemove);

      // create a map click listener for connected vector layer
      if (me.mapSelection && me.layer && me.map) {
        me.map.on('singleclick', me.onFeatureClick);
        me.mapClickRegistered = true;
      }
      this.bound_ = true;
    }
  },

  /**
   * Unbinds several events that were registered on the OL objects in this
   * class (see #bindOlEvents).
   *
   * @private
   */
  unbindOlEvents: function () {
    const me = this;

    // remove 'add' / 'remove' listener from selected feature collection
    if (me.selectedFeatures) {
      me.selectedFeatures.un('add', me.onSelectFeatAdd);
      me.selectedFeatures.un('remove', me.onSelectFeatRemove);
    }

    // remove 'singleclick' listener for connected vector layer
    if (me.mapClickRegistered) {
      me.map.un('singleclick', me.onFeatureClick);
      me.mapClickRegistered = false;
    }
  },

  /**
   * Handles 'add' event of #selectedFeatures.
   * Ensures that added feature gets the #selectStyle and preserves an
   * possibly existing feature style.
   *
   * @private
   * @param  {ol.Collection.Event} evt OL event object
   */
  onSelectFeatAdd: function (evt) {
    const me = this;
    const feat = evt.element;
    if (feat) {
      if (feat.getStyle()) {
        // we have to preserve the existing feature style
        const fid = feat.getId() || me.getRandomFid();
        me.existingFeatStyles[fid] = feat.getStyle();
        feat.setId(fid);
      }
      // apply select style
      feat.setStyle(me.selectStyle);
    }
  },

  /**
   * Handles 'remove' event of #selectedFeatures.
   * Ensures that the #selectStyle is reset on the removed feature.
   *
   * @private
   * @param  {ol.Collection.Event} evt OL event object
   */
  onSelectFeatRemove: function (evt) {
    const me = this;
    const feat = evt.element;
    if (feat) {
      const fid = feat.getId();
      if (fid && me.existingFeatStyles[fid]) {
        // restore existing feature style
        feat.setStyle(me.existingFeatStyles[fid]);
        delete me.existingFeatStyles[fid];
      } else {
        // reset feature style, so layer style gets active
        feat.setStyle();
      }
    }
  },

  /**
   * Handles the 'singleclick' event of the #map.
   * Detects if a feature of the connected #layer has been clicked and selects
   * this feature by selecting its corresponding grid row.
   *
   * @private
   * @param  {ol.MapBrowserEvent} evt OL event object
   */
  onFeatureClick: function (evt) {
    const me = this;
    const feat = me.map.forEachFeatureAtPixel(
      evt.pixel,
      function (feature) {
        return feature;
      },
      {
        layerFilter: function (layer) {
          return layer === me.layer;
        },
        hitTolerance: me.selectionTolerance,
      },
    );

    if (feat) {
      // select clicked feature in grid
      me.selectMapFeature(feat);
    }
  },

  /**
   * Selects / deselects a feature by triggering the corresponding actions in
   * the grid (e.g. selecting / deselecting a grid row).
   *
   * @private
   * @param  {ol.Feature} feature The feature to select
   */
  selectMapFeature: function (feature) {
    const me = this;
    const row = me.store.findBy(function (record, id) {
      return record.getFeature() === feature;
    });

    // deselect all if only one can be selected at a time
    if (me.getSelectionMode() === 'SINGLE') {
      me.deselectAll();
    }

    if (feature.get(me.selectedFeatureAttr)) {
      // deselect feature by deselecting grid row
      me.deselect(row);
    } else {
      // select the feature by selecting grid row
      if (row !== -1 && !me.isSelected(row)) {
        me.select(row, !this.singleSelect);
        // focus the row in the grid to ensure it is visible
        me.view.focusRow(row);
      }
    }
  },

  /**
   * Is called before the onSelectChange function of the parent class.
   * Ensures that the selected feature is added / removed to / from
   * #selectedFeatures lookup object.
   *
   * @private
   * @param  {GeoExt.data.model.Feature} record Selected / deselected record
   * @param  {boolean} isSelected Record is selected or deselected
   */
  beforeSelectChange: function (record, isSelected) {
    const me = this;
    const selFeature = record.getFeature();

    // toggle feature's selection state
    const silent = true;
    selFeature.set(me.selectedFeatureAttr, isSelected, silent);

    if (isSelected) {
      me.selectedFeatures.push(selFeature);
    } else {
      me.selectedFeatures.remove(selFeature);
    }
  },

  /**
   * Returns a random feature ID.
   *
   * @private
   * @return {string} Random feature ID
   */
  getRandomFid: function () {
    // current timestamp plus a random int between 0 and 10
    return new Date().getTime() + '' + Math.floor(Math.random() * 11);
  },
});