map/map-manager.js

import EventEmitter from 'events';

import Map from 'ol/map';
import View from 'ol/view';
import Overlay from 'ol/overlay';
import proj from 'ol/proj';
import extent from 'ol/extent';

import LayerSwitcher from './layer-switcher';

import popupTemplate from '../../templates/map/feature-popup.hbs';

/**
 * This class takes care of everything related with the Map.
 */
class MapManager extends EventEmitter {
  /**
   * @param {Object} [config] - Configuration object
   * @param {Number[]} [config.center] - The initial center for the map
   * @param {Number} [config.zoom] - The initial zoom level
   * @param {Object} [config.mapParams] - Extra params for OpenLayers Map constructor
   * @param {Object} [config.viewParams] - Extra params for OpenLayers View constructor
   */
  constructor(config = {}) {
    super();

    this.defaultCenter = config.center || [0, 0];
    this.defaultZoom = config.zoom || 1;
    this.baseLayers = [];
    this.overlayLayers = [];
    this.mapParams = config.mapParams;
    this.viewParams = config.viewParams;

    this.createMap();
    this.createOverlay();
    this.addLayerSwitcher();
  }

  /**
   * Dashboard filter string wrapper
   * @member {String}
   * @readonly
   */
  get filterString() {
    return this.dashboard.filterString;
  }

  /**
   * Dashboard filters array wrapper
   * @member {String}
   * @readonly
   */
  get filters() {
    return this.dashboard.filters;
  }

  /**
   * Creates the [OpenLayers Map](https://openlayers.org/en/latest/apidoc/ol.Map.html) and sets its initial status.
   * @private
   */
  createMap() {
    this.view = new View(Object.assign({
      center: proj.fromLonLat(this.defaultCenter),
      zoom: this.defaultZoom,
    }, this.viewParams));

    this.map = new Map(Object.assign({
      view: this.view,
      loadTilesWhileInteracting: true,
      layers: this.layers,
    }, this.mapParams));

    this.viewProjection = this.view.getProjection();
    this.viewResolution = this.view.getResolution();

    this.map.on('moveend', (event) => {
      event.extent = this.map.getView().calculateExtent(this.map.getSize());
      this.emit('mapchange', event);
    });
  }

  /**
   * Creates the [OpenLayers Overlay](https://openlayers.org/en/latest/apidoc/ol.Overlay.html) to show popups.
   * @private
   */
  createOverlay() {
    this.overlay = new Overlay({
      autoPan: true,
      autoPanAnimation: {
        duration: 250,
      },
    });
    this.map.addOverlay(this.overlay);
    this.map.on('singleclick', (event) => {
      const pixel = this.map.getEventPixel(event.originalEvent);
      const result = this.map.forEachFeatureAtPixel(pixel, (feature, layer) => ({
        feature,
        layer,
      }));
      if (result) {
        this.showFeaturePopup(result.layer, result.feature);
      } else {
        this.overlay.setElement(null);
      }
    });
  }

  /**
   * Adds the LayerSwitcher control to the map
   * @private
   */
  addLayerSwitcher() {
    this.layerSwitcher = new LayerSwitcher();
    this.layerSwitcher.manager = this;
    this.layerSwitcher.on('layerChanged', () => {
      this.overlay.setElement(null);
    });
    this.map.addControl(this.layerSwitcher);
  }

  /**
   * Renders the map and all its components into the specefied container.
   * @param {HTMLElement} container - The element where the map will be rendered
   */
  render(container) {
    this.map.setTarget(container);
    setTimeout(() => this.map.updateSize(), 100);
  }

  /**
   * Adds a BaseLayer to the map.
   * @param {BaseLayer} layer - The layer to add
   */
  addBaseLayer(layer) {
    layer.manager = this;
    this.baseLayers.push(layer);
    this.map.addLayer(layer.layer);
  }

  /**
   * Adds an OverlayLayer to the map.
   * @param {OverlayLayer} layer - The layer to add
   */
  addOverlayLayer(layer) {
    layer.manager = this;
    this.overlayLayers.push(layer);
    this.map.addLayer(layer.layer);
    layer.on('loaded', () => this.emit('loaded'));
    layer.refresh();
  }

  /**
   * Refreshes all OverLayer layers
   */
  refresh() {
    this.overlayLayers.forEach(layer => layer.refresh());
  }

  /**
   * Map center coordinates
   * @member {Number[]} center
   */
  get center() {
    return this.view.getCenter();
  }

  set center(value) {
    this.overlay.setElement(null);
    this.view.animate({
      center: value,
      duration: 2000,
    });
  }

  /**
   * Map zoom level
   * @member {Number} zoom
   */
  get zoom() {
    return this.view.getZoom();
  }

  set zoom(value) {
    this.overlay.setElement(null);
    this.view.animate({
      zoom: value,
      duration: 1000,
    });
  }

  /**
   * Centers map to definied feature
   * @param {Object} feature - Feature to center
   */
  centerToFeature(feature) {
    this.center = extent.getCenter(feature.getGeometry().getExtent());
  }

  /**
   * Fits map to definied extent
   * @param {Number[]} toExtent - Array of numbers representing an extent: [minx, miny, maxx, maxy]
   */
  fit(toExtent) {
    if (toExtent && toExtent[0] && Number.isFinite(toExtent[0])) {
      this.view.fit(toExtent, {
        size: this.map.getSize(),
        duration: 1000,
      });
    }
  }

  /**
   * Fits map to defined layer
   * @param {Layer} layer - Layer to fit in map
   */
  fitToLayer(layer) {
    if (layer) {
      this.fit(layer.source.getExtent());
    }
  }

  /**
   * Displays feature popup with information
   * @param {Object} layer - Layer to show
   * @param {Object} feature - Feature to show
   */
  showFeaturePopup(layer, feature) {
    const items = [];
    layer.popup.forEach((item) => {
      const properties = Array.isArray(item.property) ? item.property : [item.property];
      const values = properties.map(property => feature.get(property));
      let value;
      if (item.format) {
        value = item.format(...values);
      } else if (values.length > 1) {
        value = values.join(' ');
      } else {
        value = values[0];
      }
      items.push({
        title: item.title,
        value,
      });
    });
    const element = document.createElement('div');
    element.innerHTML = popupTemplate({
      properties: items,
    });
    const position = extent.getCenter(feature.getGeometry().getExtent());
    this.overlay.setElement(element);
    this.view.animate({
      center: position,
      duration: 1000,
    });
    this.overlay.setPosition(position);
  }

  /**
   * Searches and returns a specifc layer
   * @param {String} id - The layer ID to find
   * @returns {Layer}
   */
  getLayerById(id) {
    let layer = this.baseLayers.find(l => l.id === id);
    if (!layer) {
      layer = this.overlayLayers.find(l => l.id === id);
    }
    return layer;
  }
}

export default MapManager;